summaryrefslogtreecommitdiff
path: root/app/assets/javascripts/sidebar/components/labels/labels_select_widget
diff options
context:
space:
mode:
Diffstat (limited to 'app/assets/javascripts/sidebar/components/labels/labels_select_widget')
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/constants.js13
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents.vue244
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue200
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue177
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_footer.vue35
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_header.vue91
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/dropdown_value.vue125
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue73
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_labels.query.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/group_labels.query.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/merge_request_labels.query.graphql15
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql12
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/label_item.vue21
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/labels_select_root.vue441
-rw-r--r--app/assets/javascripts/sidebar/components/labels/labels_select_widget/utils.js22
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;