diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 17:22:11 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 17:22:11 +0300 |
commit | 0c872e02b2c822e3397515ec324051ff540f0cd5 (patch) | |
tree | ce2fb6ce7030e4dad0f4118d21ab6453e5938cdd /app/assets/javascripts/sidebar/components/labels/labels_select_widget | |
parent | f7e05a6853b12f02911494c4b3fe53d9540d74fc (diff) |
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'app/assets/javascripts/sidebar/components/labels/labels_select_widget')
18 files changed, 1538 insertions, 0 deletions
diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js new file mode 100644 index 00000000000..cd671b4d8f5 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js @@ -0,0 +1,13 @@ +export const SCOPED_LABEL_DELIMITER = '::'; +export const DEBOUNCE_DROPDOWN_DELAY = 200; + +export const DropdownVariant = { + Sidebar: 'sidebar', + Standalone: 'standalone', + Embedded: 'embedded', +}; + +export const LabelType = { + group: 'group', + project: 'project', +}; diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue new file mode 100644 index 00000000000..83df9056af2 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue @@ -0,0 +1,244 @@ +<script> +import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; +import { debounce } from 'lodash'; +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; +import { __, s__, sprintf } from '~/locale'; +import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; +import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; +import DropdownFooter from './dropdown_footer.vue'; +import DropdownHeader from './dropdown_header.vue'; +import { isDropdownVariantStandalone, isDropdownVariantSidebar } from './utils'; + +export default { + components: { + DropdownContentsLabelsView, + DropdownContentsCreateView, + DropdownHeader, + DropdownFooter, + GlButton, + GlDropdown, + GlDropdownItem, + GlLink, + }, + props: { + labelsCreateTitle: { + type: String, + required: true, + }, + selectedLabels: { + type: Array, + required: true, + }, + allowMultiselect: { + type: Boolean, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + dropdownButtonText: { + type: String, + required: true, + }, + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, + variant: { + type: String, + required: true, + }, + isVisible: { + type: Boolean, + required: false, + default: false, + }, + fullPath: { + type: String, + required: true, + }, + workspaceType: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: true, + }, + labelCreateType: { + type: String, + required: true, + }, + }, + data() { + return { + showDropdownContentsCreateView: false, + localSelectedLabels: [...this.selectedLabels], + isDirty: false, + searchKey: '', + }; + }, + computed: { + dropdownContentsView() { + if (this.showDropdownContentsCreateView) { + return 'dropdown-contents-create-view'; + } + return 'dropdown-contents-labels-view'; + }, + dropdownTitle() { + return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; + }, + buttonText() { + if (!this.localSelectedLabels.length) { + return this.dropdownButtonText || __('Label'); + } else if (this.localSelectedLabels.length > 1) { + return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), { + firstLabelName: this.localSelectedLabels[0].title, + remainingLabelCount: this.localSelectedLabels.length - 1, + }); + } + return this.localSelectedLabels[0].title; + }, + showDropdownFooter() { + return !this.showDropdownContentsCreateView && !this.isStandalone; + }, + isStandalone() { + return isDropdownVariantStandalone(this.variant); + }, + isSidebar() { + return isDropdownVariantSidebar(this.variant); + }, + }, + watch: { + localSelectedLabels: { + handler() { + this.isDirty = true; + }, + deep: true, + }, + isVisible(newVal) { + if (newVal) { + this.$refs.dropdown.show(); + this.isDirty = false; + this.localSelectedLabels = this.selectedLabels; + } else { + this.$refs.dropdown.hide(); + this.setLabels(); + } + }, + selectedLabels(newVal) { + if (!this.isDirty || !this.isSidebar) { + this.localSelectedLabels = newVal; + } + }, + }, + created() { + this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + }, + beforeDestroy() { + this.debouncedSearchKeyUpdate.cancel(); + }, + methods: { + toggleDropdownContentsCreateView() { + this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; + }, + toggleDropdownContent() { + this.toggleDropdownContentsCreateView(); + // Required to recalculate dropdown position as its size changes + if (this.$refs.dropdown?.$refs.dropdown) { + this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); + } + }, + setLabels() { + if (!this.isDirty) { + return; + } + this.$emit('setLabels', this.localSelectedLabels); + }, + handleDropdownHide() { + this.$emit('closeDropdown'); + if (!this.isSidebar) { + this.setLabels(); + } + }, + setSearchKey(value) { + this.searchKey = value; + }, + setFocus() { + this.$refs.header.focusInput(); + }, + hideDropdown() { + this.$refs.dropdown.hide(); + }, + showDropdown() { + this.$refs.dropdown.show(); + }, + clearSearch() { + if (!this.allowMultiselect || this.isStandalone) { + return; + } + this.searchKey = ''; + this.setFocus(); + }, + selectFirstItem() { + this.$refs.dropdownContentsView.selectFirstItem(); + }, + }, +}; +</script> + +<template> + <gl-dropdown + ref="dropdown" + :text="buttonText" + class="gl-w-full" + block + data-testid="labels-select-dropdown-contents" + data-qa-selector="labels_dropdown_content" + @hide="handleDropdownHide" + @shown="setFocus" + > + <template #header> + <dropdown-header + ref="header" + :search-key="searchKey" + :labels-create-title="labelsCreateTitle" + :labels-list-title="labelsListTitle" + :show-dropdown-contents-create-view="showDropdownContentsCreateView" + :is-standalone="isStandalone" + @toggleDropdownContentsCreateView="toggleDropdownContent" + @closeDropdown="hideDropdown" + @input="debouncedSearchKeyUpdate" + @searchEnter="selectFirstItem" + /> + </template> + <template #default> + <component + :is="dropdownContentsView" + ref="dropdownContentsView" + v-model="localSelectedLabels" + :search-key="searchKey" + :allow-multiselect="allowMultiselect" + :full-path="fullPath" + :workspace-type="workspaceType" + :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" + @hideCreateView="toggleDropdownContent" + @input="clearSearch" + /> + </template> + <template #footer> + <dropdown-footer + v-if="showDropdownFooter" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + @toggleDropdownContentsCreateView="toggleDropdownContent" + /> + </template> + </gl-dropdown> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue new file mode 100644 index 00000000000..aa1184ed314 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue @@ -0,0 +1,200 @@ +<script> +import { + GlAlert, + GlTooltipDirective, + GlButton, + GlFormInput, + GlLink, + GlLoadingIcon, +} from '@gitlab/ui'; +import produce from 'immer'; +import { createAlert } from '~/flash'; +import { __ } from '~/locale'; +import { workspaceLabelsQueries } from '../../../constants'; +import createLabelMutation from './graphql/create_label.mutation.graphql'; +import { LabelType } from './constants'; + +const errorMessage = __('Error creating label.'); + +export default { + components: { + GlAlert, + GlButton, + GlFormInput, + GlLink, + GlLoadingIcon, + }, + directives: { + GlTooltip: GlTooltipDirective, + }, + props: { + fullPath: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: true, + }, + labelCreateType: { + type: String, + required: true, + }, + workspaceType: { + type: String, + required: true, + }, + }, + data() { + return { + labelTitle: '', + selectedColor: '', + labelCreateInProgress: false, + error: undefined, + }; + }, + computed: { + disableCreate() { + return !this.labelTitle.length || !this.selectedColor.length || this.labelCreateInProgress; + }, + suggestedColors() { + const colorsMap = gon.suggested_label_colors; + return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); + }, + mutationVariables() { + const attributePath = this.labelCreateType === LabelType.group ? 'groupPath' : 'projectPath'; + + return { + title: this.labelTitle, + color: this.selectedColor, + [attributePath]: this.attrWorkspacePath, + }; + }, + }, + methods: { + getColorCode(color) { + return Object.keys(color).pop(); + }, + getColorName(color) { + return Object.values(color).pop(); + }, + handleColorClick(color) { + this.selectedColor = this.getColorCode(color); + }, + updateLabelsInCache(store, label) { + const { query } = workspaceLabelsQueries[this.workspaceType]; + + const sourceData = store.readQuery({ + query, + variables: { fullPath: this.fullPath, searchTerm: '' }, + }); + + const collator = new Intl.Collator('en'); + const data = produce(sourceData, (draftData) => { + const { nodes } = draftData.workspace.labels; + nodes.push(label); + nodes.sort((a, b) => collator.compare(a.title, b.title)); + }); + + store.writeQuery({ + query, + variables: { fullPath: this.fullPath, searchTerm: '' }, + data, + }); + }, + async createLabel() { + this.labelCreateInProgress = true; + try { + const { + data: { labelCreate }, + } = await this.$apollo.mutate({ + mutation: createLabelMutation, + variables: this.mutationVariables, + update: ( + store, + { + data: { + labelCreate: { label }, + }, + }, + ) => { + if (label) { + this.updateLabelsInCache(store, label); + } + }, + }); + if (labelCreate.errors.length) { + [this.error] = labelCreate.errors; + } else { + this.$emit('hideCreateView'); + } + } catch { + createAlert({ message: errorMessage }); + } + this.labelCreateInProgress = false; + }, + }, +}; +</script> + +<template> + <div class="labels-select-contents-create js-labels-create"> + <div class="dropdown-input"> + <gl-alert v-if="error" variant="danger" :dismissible="false" class="gl-mt-3"> + {{ error }} + </gl-alert> + <gl-form-input + v-model.trim="labelTitle" + class="gl-mt-3" + :placeholder="__('Name new label')" + :autofocus="true" + data-testid="label-title-input" + /> + </div> + <div class="dropdown-content gl-px-3"> + <div class="suggest-colors suggest-colors-dropdown gl-mt-0! gl-mb-3! gl-mb-0"> + <gl-link + v-for="(color, index) in suggestedColors" + :key="index" + v-gl-tooltip:tooltipcontainer + :style="{ backgroundColor: getColorCode(color) }" + :title="getColorName(color)" + @click.prevent="handleColorClick(color)" + /> + </div> + <div class="color-input-container gl-display-flex"> + <span + class="dropdown-label-color-preview gl-relative gl-display-inline-block" + data-testid="selected-color" + :style="{ backgroundColor: selectedColor }" + ></span> + <gl-form-input + v-model.trim="selectedColor" + class="gl-rounded-top-left-none gl-rounded-bottom-left-none gl-mb-2" + :placeholder="__('Use custom color #FF0000')" + data-testid="selected-color-text" + /> + </div> + </div> + <div class="dropdown-actions gl-display-flex gl-justify-content-space-between gl-pt-3 gl-px-3"> + <gl-button + :disabled="disableCreate" + category="primary" + variant="confirm" + class="gl-display-flex gl-align-items-center" + data-testid="create-button" + @click="createLabel" + > + <gl-loading-icon v-if="labelCreateInProgress" size="sm" :inline="true" class="mr-1" /> + {{ __('Create') }} + </gl-button> + <gl-button + class="js-btn-cancel-create" + data-testid="cancel-button" + @click.stop="$emit('hideCreateView')" + > + {{ __('Cancel') }} + </gl-button> + </div> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue new file mode 100644 index 00000000000..c1939dc7785 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue @@ -0,0 +1,177 @@ +<script> +import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; +import fuzzaldrinPlus from 'fuzzaldrin-plus'; +import { createAlert } from '~/flash'; +import { getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { __ } from '~/locale'; +import { workspaceLabelsQueries } from '../../../constants'; +import LabelItem from './label_item.vue'; + +export default { + components: { + GlDropdownForm, + GlDropdownItem, + GlLoadingIcon, + GlIntersectionObserver, + LabelItem, + }, + model: { + prop: 'localSelectedLabels', + }, + props: { + allowMultiselect: { + type: Boolean, + required: true, + }, + localSelectedLabels: { + type: Array, + required: true, + }, + fullPath: { + type: String, + required: true, + }, + searchKey: { + type: String, + required: true, + }, + workspaceType: { + type: String, + required: true, + }, + }, + data() { + return { + labels: [], + isVisible: false, + }; + }, + apollo: { + labels: { + query() { + return workspaceLabelsQueries[this.workspaceType].query; + }, + variables() { + return { + fullPath: this.fullPath, + searchTerm: this.searchKey, + }; + }, + skip() { + return this.searchKey.length === 1 || !this.isVisible; + }, + update: (data) => data.workspace?.labels?.nodes || [], + error() { + createAlert({ message: __('Error fetching labels.') }); + }, + }, + }, + computed: { + labelsFetchInProgress() { + return this.$apollo.queries.labels.loading; + }, + localSelectedLabelsIds() { + return this.localSelectedLabels.map((label) => getIdFromGraphQLId(label.id)); + }, + visibleLabels() { + if (this.searchKey) { + return fuzzaldrinPlus.filter(this.labels, this.searchKey, { + key: ['title'], + }); + } + return this.labels; + }, + showNoMatchingResultsMessage() { + return Boolean(this.searchKey) && this.visibleLabels.length === 0; + }, + shouldHighlightFirstItem() { + return this.searchKey !== '' && this.visibleLabels.length > 0; + }, + }, + methods: { + isLabelSelected(label) { + return this.localSelectedLabelsIds.includes(getIdFromGraphQLId(label.id)); + }, + /** + * This method scrolls item from dropdown into + * the view if it is off the viewable area of the + * container. + */ + scrollIntoViewIfNeeded() { + const highlightedLabel = this.$refs.labelsListContainer.querySelector('.is-focused'); + + if (highlightedLabel) { + const container = this.$refs.labelsListContainer.getBoundingClientRect(); + const label = highlightedLabel.getBoundingClientRect(); + + if (label.bottom > container.bottom) { + this.$refs.labelsListContainer.scrollTop += label.bottom - container.bottom; + } else if (label.top < container.top) { + this.$refs.labelsListContainer.scrollTop -= container.top - label.top; + } + } + }, + updateSelectedLabels(label) { + let labels; + if (this.isLabelSelected(label)) { + labels = this.localSelectedLabels.filter( + ({ id }) => id !== getIdFromGraphQLId(label.id) && id !== label.id, + ); + } else { + labels = [...this.localSelectedLabels, label]; + } + this.$emit('input', labels); + }, + handleLabelClick(label) { + this.updateSelectedLabels(label); + if (!this.allowMultiselect) { + this.$emit('closeDropdown', this.localSelectedLabels); + } + }, + onDropdownAppear() { + this.isVisible = true; + }, + selectFirstItem() { + if (this.shouldHighlightFirstItem) { + this.handleLabelClick(this.visibleLabels[0]); + } + }, + }, +}; +</script> + +<template> + <gl-intersection-observer @appear="onDropdownAppear"> + <gl-dropdown-form class="labels-select-contents-list js-labels-list"> + <div ref="labelsListContainer" data-testid="dropdown-content"> + <gl-loading-icon + v-if="labelsFetchInProgress" + class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3" + size="lg" + /> + <template v-else> + <gl-dropdown-item + v-for="(label, index) in visibleLabels" + :key="label.id" + :is-checked="isLabelSelected(label)" + is-check-centered + is-check-item + :active="shouldHighlightFirstItem && index === 0" + active-class="is-focused" + data-testid="labels-list" + @click.native.capture.stop="handleLabelClick(label)" + > + <label-item :label="label" /> + </gl-dropdown-item> + <gl-dropdown-item + v-show="showNoMatchingResultsMessage" + class="gl-p-3 gl-text-center" + data-testid="no-results" + > + {{ __('No matching results') }} + </gl-dropdown-item> + </template> + </div> + </gl-dropdown-form> + </gl-intersection-observer> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue new file mode 100644 index 00000000000..e67e704ffb8 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue @@ -0,0 +1,35 @@ +<script> +import { GlDropdownItem } from '@gitlab/ui'; + +export default { + components: { + GlDropdownItem, + }, + inject: ['allowLabelCreate', 'labelsManagePath'], + props: { + footerCreateLabelTitle: { + type: String, + required: true, + }, + footerManageLabelTitle: { + type: String, + required: true, + }, + }, +}; +</script> + +<template> + <div data-testid="dropdown-footer"> + <gl-dropdown-item + v-if="allowLabelCreate" + data-testid="create-label-button" + @click.capture.native.stop="$emit('toggleDropdownContentsCreateView')" + > + {{ footerCreateLabelTitle }} + </gl-dropdown-item> + <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop> + {{ footerManageLabelTitle }} + </gl-dropdown-item> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue new file mode 100644 index 00000000000..154a8e866d0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue @@ -0,0 +1,91 @@ +<script> +import { GlButton, GlSearchBoxByType } from '@gitlab/ui'; + +export default { + components: { + GlButton, + GlSearchBoxByType, + }, + props: { + labelsCreateTitle: { + type: String, + required: true, + }, + labelsListTitle: { + type: String, + required: true, + }, + showDropdownContentsCreateView: { + type: Boolean, + required: true, + }, + labelsFetchInProgress: { + type: Boolean, + required: false, + default: false, + }, + searchKey: { + type: String, + required: true, + }, + isStandalone: { + type: Boolean, + required: false, + default: false, + }, + }, + computed: { + dropdownTitle() { + return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; + }, + }, + methods: { + focusInput() { + this.$refs.searchInput?.focusInput(); + }, + }, +}; +</script> + +<template> + <div data-testid="dropdown-header"> + <div + v-if="!isStandalone" + class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3! gl-mb-0" + data-testid="dropdown-header-title" + > + <gl-button + v-if="showDropdownContentsCreateView" + :aria-label="__('Go back')" + variant="link" + size="small" + class="js-btn-back dropdown-header-button gl-p-0" + icon="arrow-left" + data-testid="go-back-button" + @click.stop="$emit('toggleDropdownContentsCreateView')" + /> + <span class="gl-flex-grow-1">{{ dropdownTitle }}</span> + <gl-button + :aria-label="__('Close')" + variant="link" + size="small" + class="dropdown-header-button gl-p-0!" + icon="close" + data-testid="close-button" + data-qa-selector="close_labels_dropdown_button" + @click="$emit('closeDropdown')" + /> + </div> + <gl-search-box-by-type + v-if="!showDropdownContentsCreateView" + ref="searchInput" + :value="searchKey" + :placeholder="__('Search labels')" + :disabled="labelsFetchInProgress" + data-qa-selector="dropdown_input_field" + data-testid="dropdown-input-field" + @input="$emit('input', $event)" + @keydown.enter="$emit('searchEnter', $event)" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue new file mode 100644 index 00000000000..57e3ee4aaa5 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue @@ -0,0 +1,125 @@ +<script> +import { GlIcon, GlLabel, GlTooltipDirective } from '@gitlab/ui'; +import { sortBy } from 'lodash'; +import { isScopedLabel } from '~/lib/utils/common_utils'; +import { s__, sprintf } from '~/locale'; + +export default { + directives: { + GlTooltip: GlTooltipDirective, + }, + components: { + GlIcon, + GlLabel, + }, + inject: ['allowScopedLabels'], + props: { + disableLabels: { + type: Boolean, + required: false, + default: false, + }, + selectedLabels: { + type: Array, + required: true, + }, + allowLabelRemove: { + type: Boolean, + required: true, + }, + labelsFilterBasePath: { + type: String, + required: true, + }, + labelsFilterParam: { + type: String, + required: true, + }, + }, + computed: { + sortedSelectedLabels() { + return sortBy(this.selectedLabels, (label) => (isScopedLabel(label) ? 0 : 1)); + }, + labelsList() { + const labelsString = this.selectedLabels.length + ? this.selectedLabels + .slice(0, 5) + .map((label) => label.title) + .join(', ') + : s__('LabelSelect|Labels'); + + if (this.selectedLabels.length > 5) { + return sprintf(s__('LabelSelect|%{labelsString}, and %{remainingLabelCount} more'), { + labelsString, + remainingLabelCount: this.selectedLabels.length - 5, + }); + } + + return labelsString; + }, + }, + methods: { + labelFilterUrl(label) { + return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent( + label.title, + )}`; + }, + scopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + removeLabel(labelId) { + this.$emit('onLabelRemove', labelId); + }, + handleCollapsedClick() { + this.$emit('onCollapsedValueClick'); + }, + }, +}; +</script> + +<template> + <div + :class="{ + 'has-labels': selectedLabels.length, + }" + class="value issuable-show-labels js-value" + data-testid="value-wrapper" + > + <div + v-gl-tooltip.left.viewport + :title="labelsList" + class="sidebar-collapsed-icon" + @click="handleCollapsedClick" + > + <gl-icon name="labels" /> + <span class="collapse-truncated-title gl-pt-2 gl-px-3 gl-font-sm">{{ + selectedLabels.length + }}</span> + </div> + <span + v-if="!selectedLabels.length" + class="text-secondary hide-collapsed" + data-testid="empty-placeholder" + > + <slot></slot> + </span> + <template v-else> + <gl-label + v-for="label in sortedSelectedLabels" + :key="label.id" + class="hide-collapsed" + data-qa-selector="selected_label_content" + :data-qa-label-name="label.title" + :title="label.title" + :description="label.description" + :background-color="label.color" + :target="labelFilterUrl(label)" + :scoped="scopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disableLabels" + tooltip-placement="top" + @close="removeLabel(label.id)" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue new file mode 100644 index 00000000000..3a93fc7f3b2 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue @@ -0,0 +1,73 @@ +<script> +import { GlLabel } from '@gitlab/ui'; +import { sortBy } from 'lodash'; +import { isScopedLabel } from '~/lib/utils/common_utils'; + +export default { + components: { + GlLabel, + }, + inject: ['allowScopedLabels'], + props: { + disabled: { + type: Boolean, + required: false, + default: false, + }, + selectedLabels: { + type: Array, + required: true, + }, + allowLabelRemove: { + type: Boolean, + required: true, + }, + labelsFilterBasePath: { + type: String, + required: true, + }, + labelsFilterParam: { + type: String, + required: true, + }, + }, + computed: { + sortedSelectedLabels() { + return sortBy(this.selectedLabels, (label) => isScopedLabel(label)); + }, + }, + methods: { + buildFilterUrl({ title }) { + const { labelsFilterBasePath: basePath, labelsFilterParam: filterParam } = this; + + return `${basePath}?${filterParam}[]=${encodeURIComponent(title)}`; + }, + showScopedLabel(label) { + return this.allowScopedLabels && isScopedLabel(label); + }, + removeLabel(labelId) { + this.$emit('onLabelRemove', labelId); + }, + }, +}; +</script> + +<template> + <div> + <gl-label + v-for="label in sortedSelectedLabels" + :key="label.id" + class="gl-mr-2 gl-mb-2" + :data-qa-label-name="label.title" + :title="label.title" + :description="label.description" + :background-color="label.color" + :target="buildFilterUrl(label)" + :scoped="showScopedLabel(label)" + :show-close-button="allowLabelRemove" + :disabled="disabled" + tooltip-placement="top" + @close="removeLabel(label.id)" + /> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql new file mode 100644 index 00000000000..a9c791091fc --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +mutation createLabel($title: String!, $color: String, $projectPath: ID, $groupPath: ID) { + labelCreate( + input: { title: $title, color: $color, projectPath: $projectPath, groupPath: $groupPath } + ) { + label { + ...Label + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql new file mode 100644 index 00000000000..c442c17eb88 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql @@ -0,0 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query epicLabels($fullPath: ID!, $iid: ID) { + workspace: group(fullPath: $fullPath) { + id + issuable: epic(iid: $iid) { + id + labels { + nodes { + ...Label + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql new file mode 100644 index 00000000000..cb054e2968f --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql @@ -0,0 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +mutation updateEpicLabels($input: UpdateEpicInput!) { + updateIssuableLabels: updateEpic(input: $input) { + issuable: epic { + id + labels { + nodes { + ...Label + } + } + } + errors + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql new file mode 100644 index 00000000000..ce1a69f84c0 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query groupLabels($fullPath: ID!, $searchTerm: String) { + workspace: group(fullPath: $fullPath) { + id + labels(searchTerm: $searchTerm, onlyGroupLabels: true, includeAncestorGroups: true) { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql new file mode 100644 index 00000000000..2904857270e --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql @@ -0,0 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query issueLabels($fullPath: ID!, $iid: String) { + workspace: project(fullPath: $fullPath) { + id + issuable: issue(iid: $iid) { + id + labels { + nodes { + ...Label + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql new file mode 100644 index 00000000000..e0cdfd91658 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql @@ -0,0 +1,15 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query mergeRequestLabels($fullPath: ID!, $iid: String!) { + workspace: project(fullPath: $fullPath) { + id + issuable: mergeRequest(iid: $iid) { + id + labels { + nodes { + ...Label + } + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql new file mode 100644 index 00000000000..a7c24620aad --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql @@ -0,0 +1,12 @@ +#import "~/graphql_shared/fragments/label.fragment.graphql" + +query projectLabels($fullPath: ID!, $searchTerm: String) { + workspace: project(fullPath: $fullPath) { + id + labels(searchTerm: $searchTerm, includeAncestorGroups: true) { + nodes { + ...Label + } + } + } +} diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue new file mode 100644 index 00000000000..314ffbaf84c --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue @@ -0,0 +1,21 @@ +<script> +export default { + props: { + label: { + type: Object, + required: true, + }, + }, +}; +</script> + +<template> + <div class="gl-display-flex gl-align-items-center gl-word-break-word"> + <span + class="dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3" + :style="{ 'background-color': label.color }" + data-testid="label-color-box" + ></span> + <span>{{ label.title }}</span> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue new file mode 100644 index 00000000000..b7b4bbac661 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue @@ -0,0 +1,441 @@ +<script> +import { debounce } from 'lodash'; +import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; +import { MutationOperationMode, getIdFromGraphQLId } from '~/graphql_shared/utils'; +import { createAlert } from '~/flash'; +import glFeatureFlagsMixin from '~/vue_shared/mixins/gl_feature_flags_mixin'; +import { IssuableType } from '~/issues/constants'; + +import { __ } from '~/locale'; +import { issuableLabelsQueries } from '../../../constants'; +import SidebarEditableItem from '../../sidebar_editable_item.vue'; +import { DEBOUNCE_DROPDOWN_DELAY, DropdownVariant } from './constants'; +import DropdownContents from './dropdown_contents.vue'; +import DropdownValue from './dropdown_value.vue'; +import EmbeddedLabelsList from './embedded_labels_list.vue'; +import { + isDropdownVariantSidebar, + isDropdownVariantStandalone, + isDropdownVariantEmbedded, +} from './utils'; + +export default { + components: { + DropdownValue, + DropdownContents, + EmbeddedLabelsList, + SidebarEditableItem, + }, + mixins: [glFeatureFlagsMixin()], + inject: { + allowLabelEdit: { + default: false, + }, + }, + props: { + iid: { + type: String, + required: false, + default: '', + }, + fullPath: { + type: String, + required: true, + }, + allowLabelRemove: { + type: Boolean, + required: false, + default: false, + }, + allowMultiselect: { + type: Boolean, + required: false, + default: false, + }, + showEmbeddedLabelsList: { + type: Boolean, + required: false, + default: false, + }, + variant: { + type: String, + required: false, + default: DropdownVariant.Sidebar, + }, + labelsFilterBasePath: { + type: String, + required: false, + default: '', + }, + labelsFilterParam: { + type: String, + required: false, + default: 'label_name', + }, + dropdownButtonText: { + type: String, + required: false, + default: __('Label'), + }, + labelsListTitle: { + type: String, + required: false, + default: __('Assign labels'), + }, + labelsCreateTitle: { + type: String, + required: false, + default: __('Create group label'), + }, + footerCreateLabelTitle: { + type: String, + required: false, + default: __('Create group label'), + }, + footerManageLabelTitle: { + type: String, + required: false, + default: __('Manage group labels'), + }, + issuableType: { + type: String, + required: true, + }, + workspaceType: { + type: String, + required: true, + }, + attrWorkspacePath: { + type: String, + required: true, + }, + labelCreateType: { + type: String, + required: true, + }, + selectedLabels: { + type: Array, + required: false, + default: () => [], + }, + }, + data() { + return { + contentIsOnViewport: true, + issuable: null, + labelsSelectInProgress: false, + oldIid: null, + sidebarExpandedOnClick: false, + }; + }, + computed: { + isLoading() { + return this.labelsSelectInProgress || this.$apollo.queries.issuable.loading; + }, + issuableLabelIds() { + return this.issuableLabels.map((label) => label.id); + }, + issuableLabels() { + if (this.iid !== '') { + return this.issuable?.labels.nodes || []; + } + + return this.selectedLabels || []; + }, + issuableId() { + return this.issuable?.id; + }, + isRealtimeEnabled() { + return this.glFeatures.realtimeLabels; + }, + isLabelListEnabled() { + return this.showEmbeddedLabelsList && isDropdownVariantEmbedded(this.variant); + }, + }, + apollo: { + issuable: { + query() { + return issuableLabelsQueries[this.issuableType].issuableQuery; + }, + skip() { + return !isDropdownVariantSidebar(this.variant); + }, + variables() { + return { + iid: this.iid, + fullPath: this.fullPath, + }; + }, + update(data) { + return data.workspace?.issuable; + }, + error() { + createAlert({ message: __('Error fetching labels.') }); + }, + subscribeToMore: { + document() { + return issuableLabelsSubscription; + }, + variables() { + return { + issuableId: this.issuableId, + }; + }, + skip() { + return !this.issuableId || !this.isDropdownVariantSidebar; + }, + updateQuery( + _, + { + subscriptionData: { + data: { issuableLabelsUpdated }, + }, + }, + ) { + if (issuableLabelsUpdated) { + const { + id, + labels: { nodes }, + } = issuableLabelsUpdated; + this.$emit('updateSelectedLabels', { id, labels: nodes }); + } + }, + }, + }, + }, + watch: { + iid(_, oldVal) { + this.oldIid = oldVal; + }, + }, + mounted() { + document.addEventListener('toggleSidebarRevealLabelsDropdown', this.handleCollapsedValueClick); + }, + beforeDestroy() { + document.removeEventListener( + 'toggleSidebarRevealLabelsDropdown', + this.handleCollapsedValueClick, + ); + }, + methods: { + handleDropdownClose(labels) { + if (this.iid !== '') { + this.updateSelectedLabels(this.getUpdateVariables(labels)); + } else { + this.$emit('updateSelectedLabels', { labels }); + } + + this.collapseEditableItem(); + }, + collapseEditableItem() { + this.$refs.editable?.collapse(); + if (this.sidebarExpandedOnClick) { + this.sidebarExpandedOnClick = false; + this.$emit('toggleCollapse'); + } + }, + handleCollapsedValueClick() { + this.sidebarExpandedOnClick = true; + this.$emit('toggleCollapse'); + debounce(() => { + this.$refs.editable.toggle(); + this.$refs.dropdownContents.showDropdown(); + }, DEBOUNCE_DROPDOWN_DELAY)(); + }, + getUpdateVariables(labels) { + let labelIds = []; + + labelIds = labels.map(({ id }) => id); + const currentIid = this.oldIid || this.iid; + + const updateVariables = { + iid: currentIid, + projectPath: this.fullPath, + labelIds, + }; + + switch (this.issuableType) { + case IssuableType.Issue: + return updateVariables; + case IssuableType.MergeRequest: + return { + ...updateVariables, + operationMode: MutationOperationMode.Replace, + }; + case IssuableType.Epic: + return { + iid: currentIid, + groupPath: this.fullPath, + addLabelIds: labelIds.map((id) => getIdFromGraphQLId(id)), + removeLabelIds: this.issuableLabelIds + .filter((id) => !labelIds.includes(id)) + .map((id) => getIdFromGraphQLId(id)), + }; + default: + return {}; + } + }, + updateSelectedLabels(inputVariables) { + this.labelsSelectInProgress = true; + + this.$apollo + .mutate({ + mutation: issuableLabelsQueries[this.issuableType].mutation, + variables: { input: inputVariables }, + }) + .then(({ data }) => { + if (data.updateIssuableLabels?.errors?.length) { + throw new Error(); + } + + this.$emit('updateSelectedLabels', { + id: data.updateIssuableLabels?.issuable?.id, + labels: data.updateIssuableLabels?.issuable?.labels?.nodes, + }); + }) + .catch((error) => + createAlert({ + message: __('An error occurred while updating labels.'), + captureError: true, + error, + }), + ) + .finally(() => { + this.labelsSelectInProgress = false; + }); + }, + getRemoveVariables(labelId) { + const removeVariables = { + iid: this.iid, + projectPath: this.fullPath, + }; + + switch (this.issuableType) { + case IssuableType.Issue: + return { + ...removeVariables, + removeLabelIds: [labelId], + }; + case IssuableType.MergeRequest: + return { + ...removeVariables, + labelIds: [labelId], + operationMode: MutationOperationMode.Remove, + }; + case IssuableType.Epic: + return { + iid: this.iid, + removeLabelIds: [getIdFromGraphQLId(labelId)], + groupPath: this.fullPath, + }; + default: + return {}; + } + }, + handleLabelRemove(labelId) { + if (this.iid !== '') { + this.updateSelectedLabels(this.getRemoveVariables(labelId)); + } + + this.$emit('onLabelRemove', labelId); + }, + isDropdownVariantSidebar, + isDropdownVariantStandalone, + isDropdownVariantEmbedded, + }, +}; +</script> + +<template> + <div + class="labels-select-wrapper gl-relative" + :class="{ + 'is-standalone': isDropdownVariantStandalone(variant), + 'is-embedded': isDropdownVariantEmbedded(variant), + }" + data-testid="sidebar-labels" + data-qa-selector="labels_block" + > + <template v-if="isDropdownVariantSidebar(variant)"> + <sidebar-editable-item + ref="editable" + :title="__('Labels')" + :loading="isLoading" + :can-edit="allowLabelEdit" + @open="oldIid = null" + > + <template #collapsed> + <dropdown-value + :disable-labels="labelsSelectInProgress" + :selected-labels="issuableLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + @onLabelRemove="handleLabelRemove" + @onCollapsedValueClick="handleCollapsedValueClick" + > + <slot></slot> + </dropdown-value> + </template> + <template #default="{ edit }"> + <dropdown-value + :disable-labels="labelsSelectInProgress" + :selected-labels="issuableLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + class="gl-mb-2" + @onLabelRemove="handleLabelRemove" + > + <slot></slot> + </dropdown-value> + <dropdown-contents + ref="dropdownContents" + :dropdown-button-text="dropdownButtonText" + :allow-multiselect="allowMultiselect" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="issuableLabels" + :variant="variant" + :is-visible="edit" + :full-path="fullPath" + :workspace-type="workspaceType" + :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" + @setLabels="handleDropdownClose" + @closeDropdown="collapseEditableItem" + /> + </template> + </sidebar-editable-item> + </template> + <template v-else> + <dropdown-contents + ref="dropdownContents" + :allow-multiselect="allowMultiselect" + :dropdown-button-text="dropdownButtonText" + :labels-list-title="labelsListTitle" + :footer-create-label-title="footerCreateLabelTitle" + :footer-manage-label-title="footerManageLabelTitle" + :labels-create-title="labelsCreateTitle" + :selected-labels="issuableLabels" + :variant="variant" + :full-path="fullPath" + :workspace-type="workspaceType" + :attr-workspace-path="attrWorkspacePath" + :label-create-type="labelCreateType" + @setLabels="handleDropdownClose" + /> + <embedded-labels-list + v-if="isLabelListEnabled" + :disabled="labelsSelectInProgress" + :selected-labels="issuableLabels" + :allow-label-remove="allowLabelRemove" + :labels-filter-base-path="labelsFilterBasePath" + :labels-filter-param="labelsFilterParam" + @onLabelRemove="handleLabelRemove" + /> + </template> + </div> +</template> diff --git a/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js new file mode 100644 index 00000000000..b5cd946a189 --- /dev/null +++ b/app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js @@ -0,0 +1,22 @@ +import { DropdownVariant } from './constants'; + +/** + * Returns boolean representing whether dropdown variant + * is `sidebar` + * @param {string} variant + */ +export const isDropdownVariantSidebar = (variant) => variant === DropdownVariant.Sidebar; + +/** + * Returns boolean representing whether dropdown variant + * is `standalone` + * @param {string} variant + */ +export const isDropdownVariantStandalone = (variant) => variant === DropdownVariant.Standalone; + +/** + * Returns boolean representing whether dropdown variant + * is `embedded` + * @param {string} variant + */ +export const isDropdownVariantEmbedded = (variant) => variant === DropdownVariant.Embedded; |