Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-06-16 21:25:58 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-06-16 21:25:58 +0300
commita5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch)
treefb69158581673816a8cd895f9d352dcb3c678b1e /app/assets/javascripts/vue_shared/components/sidebar
parentd16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff)
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/sidebar')
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue191
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue86
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue92
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue37
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue22
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue28
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue31
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue65
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue7
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue (renamed from app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue)0
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue9
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js5
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue42
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue44
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue119
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue221
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue39
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue67
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue82
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue327
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js58
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js52
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js12
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js20
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js70
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js29
-rw-r--r--app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql20
27 files changed, 1218 insertions, 557 deletions
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
deleted file mode 100644
index 88c4d132d61..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/base.vue
+++ /dev/null
@@ -1,191 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-import $ from 'jquery';
-import LabelsSelect from '~/labels_select';
-import { __ } from '~/locale';
-import DropdownHiddenInput from '~/vue_shared/components/dropdown/dropdown_hidden_input.vue';
-
-import { DropdownVariant } from '../labels_select_vue/constants';
-import DropdownButton from './dropdown_button.vue';
-import DropdownCreateLabel from './dropdown_create_label.vue';
-import DropdownFooter from './dropdown_footer.vue';
-import DropdownHeader from './dropdown_header.vue';
-import DropdownSearchInput from './dropdown_search_input.vue';
-import DropdownTitle from './dropdown_title.vue';
-import DropdownValue from './dropdown_value.vue';
-import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
-
-export default {
- DropdownVariant,
- components: {
- DropdownTitle,
- DropdownValue,
- DropdownValueCollapsed,
- DropdownButton,
- DropdownHiddenInput,
- DropdownHeader,
- DropdownSearchInput,
- DropdownFooter,
- DropdownCreateLabel,
- GlLoadingIcon,
- },
- props: {
- showCreate: {
- type: Boolean,
- required: false,
- default: false,
- },
- isProject: {
- type: Boolean,
- required: false,
- default: false,
- },
- abilityName: {
- type: String,
- required: true,
- },
- context: {
- type: Object,
- required: true,
- },
- namespace: {
- type: String,
- required: false,
- default: '',
- },
- updatePath: {
- type: String,
- required: false,
- default: '',
- },
- labelsPath: {
- type: String,
- required: true,
- },
- labelsWebUrl: {
- type: String,
- required: false,
- default: '',
- },
- labelFilterBasePath: {
- type: String,
- required: false,
- default: '',
- },
- canEdit: {
- type: Boolean,
- required: false,
- default: false,
- },
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- variant: {
- type: String,
- required: false,
- default: DropdownVariant.Sidebar,
- },
- },
- computed: {
- hiddenInputName() {
- return this.showCreate ? `${this.abilityName}[label_names][]` : 'label_id[]';
- },
- createLabelTitle() {
- if (this.isProject) {
- return __('Create project label');
- }
-
- return __('Create group label');
- },
- manageLabelsTitle() {
- if (this.isProject) {
- return __('Manage project labels');
- }
-
- return __('Manage group labels');
- },
- },
- mounted() {
- this.labelsDropdown = new LabelsSelect(this.$refs.dropdownButton, {
- handleClick: this.handleClick,
- });
- $(this.$refs.dropdown).on('hidden.gl.dropdown', this.handleDropdownHidden);
- },
- methods: {
- handleClick(label) {
- this.$emit('onLabelClick', label);
- },
- handleCollapsedValueClick() {
- this.$emit('toggleCollapse');
- },
- handleDropdownHidden() {
- this.$emit('onDropdownClose');
- },
- },
-};
-</script>
-
-<template>
- <div class="block labels js-labels-block">
- <dropdown-value-collapsed
- v-if="showCreate && variant === $options.DropdownVariant.Sidebar"
- :labels="context.labels"
- @onValueClick="handleCollapsedValueClick"
- />
- <dropdown-title :can-edit="canEdit" />
- <dropdown-value
- :labels="context.labels"
- :label-filter-base-path="labelFilterBasePath"
- :enable-scoped-labels="enableScopedLabels"
- >
- <slot></slot>
- </dropdown-value>
- <div v-if="canEdit" class="selectbox js-selectbox" style="display: none">
- <dropdown-hidden-input
- v-for="label in context.labels"
- :key="label.id"
- :name="hiddenInputName"
- :value="label.id"
- />
- <div ref="dropdown" class="dropdown">
- <dropdown-button
- :ability-name="abilityName"
- :field-name="hiddenInputName"
- :update-path="updatePath"
- :labels-path="labelsPath"
- :namespace="namespace"
- :labels="context.labels"
- :show-extra-options="!showCreate || variant !== $options.DropdownVariant.Sidebar"
- :enable-scoped-labels="enableScopedLabels"
- />
- <div
- class="dropdown-menu dropdown-select dropdown-menu-paging dropdown-menu-labels dropdown-menu-selectable"
- >
- <div class="dropdown-page-one">
- <dropdown-header v-if="showCreate && variant === $options.DropdownVariant.Sidebar" />
- <dropdown-search-input />
- <div class="dropdown-content" data-qa-selector="labels_dropdown_content"></div>
- <div class="dropdown-loading">
- <gl-loading-icon
- class="gl-display-flex gl-justify-content-center gl-align-items-center gl-h-full"
- />
- </div>
- <dropdown-footer
- v-if="showCreate"
- :labels-web-url="labelsWebUrl"
- :create-label-title="createLabelTitle"
- :manage-labels-title="manageLabelsTitle"
- />
- </div>
- <dropdown-create-label
- v-if="showCreate"
- :is-project="isProject"
- :header-title="createLabelTitle"
- />
- </div>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
deleted file mode 100644
index 94cf1f84ec3..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_button.vue
+++ /dev/null
@@ -1,86 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-import { __, s__, sprintf } from '~/locale';
-
-export default {
- components: {
- GlIcon,
- },
- props: {
- abilityName: {
- type: String,
- required: true,
- },
- fieldName: {
- type: String,
- required: true,
- },
- updatePath: {
- type: String,
- required: true,
- },
- labelsPath: {
- type: String,
- required: true,
- },
- namespace: {
- type: String,
- required: true,
- },
- labels: {
- type: Array,
- required: true,
- },
- showExtraOptions: {
- type: Boolean,
- required: true,
- },
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- dropdownToggleText() {
- if (this.labels.length === 0) {
- return __('Label');
- }
-
- if (this.labels.length > 1) {
- return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
- firstLabelName: this.labels[0].title,
- remainingLabelCount: this.labels.length - 1,
- });
- }
-
- return this.labels[0].title;
- },
- },
-};
-</script>
-
-<template>
- <!-- eslint-disable @gitlab/vue-no-data-toggle -->
- <button
- ref="dropdownButton"
- :class="{ 'js-extra-options': showExtraOptions }"
- :data-ability-name="abilityName"
- :data-field-name="fieldName"
- :data-issue-update="updatePath"
- :data-labels="labelsPath"
- :data-namespace-path="namespace"
- :data-show-any="showExtraOptions"
- :data-scoped-labels="enableScopedLabels"
- type="button"
- class="dropdown-menu-toggle wide js-label-select js-multiselect js-context-config-modal"
- data-toggle="dropdown"
- >
- <span class="dropdown-toggle-text"> {{ dropdownToggleText }} </span>
- <gl-icon
- name="chevron-down"
- class="gl-absolute gl-top-3 gl-right-3 gl-text-gray-500"
- :size="16"
- />
- </button>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
deleted file mode 100644
index 795f16f4efc..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue
+++ /dev/null
@@ -1,92 +0,0 @@
-<script>
-import { GlButton, GlTooltipDirective } from '@gitlab/ui';
-import { __ } from '~/locale';
-
-export default {
- components: {
- GlButton,
- },
- directives: {
- GlTooltip: GlTooltipDirective,
- },
- props: {
- headerTitle: {
- type: String,
- required: false,
- default: () => __('Create new label'),
- },
- },
- created() {
- const rawLabelsColors = gon.suggested_label_colors;
- this.suggestedColors = Object.keys(rawLabelsColors).map((colorCode) => ({
- colorCode,
- title: rawLabelsColors[colorCode],
- }));
- },
-};
-</script>
-
-<template>
- <div class="dropdown-page-two dropdown-new-label">
- <div
- class="dropdown-title gl-display-flex gl-justify-content-space-between gl-align-items-center"
- >
- <gl-button
- :aria-label="__('Go back')"
- category="tertiary"
- class="dropdown-menu-back"
- icon="arrow-left"
- size="small"
- />
- {{ headerTitle }}
- <gl-button
- :aria-label="__('Close')"
- category="tertiary"
- class="dropdown-menu-close"
- icon="close"
- size="small"
- />
- </div>
- <div class="dropdown-content">
- <div class="dropdown-labels-error js-label-error"></div>
- <input
- id="new_label_name"
- :placeholder="__('Name new label')"
- type="text"
- class="default-dropdown-input"
- />
- <div class="suggest-colors suggest-colors-dropdown">
- <a
- v-for="(color, index) in suggestedColors"
- :key="index"
- v-gl-tooltip
- :data-color="color.colorCode"
- :style="{
- backgroundColor: color.colorCode,
- }"
- :title="color.title"
- href="#"
- >
- &nbsp;
- </a>
- </div>
- <div class="dropdown-label-color-input">
- <div class="dropdown-label-color-preview js-dropdown-label-color-preview"></div>
- <input
- id="new_label_color"
- :placeholder="__('Assign custom color like #FF0000')"
- type="text"
- class="default-dropdown-input"
- />
- </div>
- <div class="clearfix">
- <gl-button category="secondary" class="float-left js-new-label-btn disabled">
- {{ __('Create') }}
- </gl-button>
- <gl-button category="secondary" class="float-right js-cancel-label-btn">
- {{ __('Cancel') }}
- </gl-button>
- </div>
- </div>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
deleted file mode 100644
index ebbd8d119b5..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_footer.vue
+++ /dev/null
@@ -1,37 +0,0 @@
-<script>
-import { __ } from '~/locale';
-
-export default {
- props: {
- labelsWebUrl: {
- type: String,
- required: true,
- },
- createLabelTitle: {
- type: String,
- required: false,
- default: () => __('Create new label'),
- },
- manageLabelsTitle: {
- type: String,
- required: false,
- default: () => __('Manage labels'),
- },
- },
-};
-</script>
-
-<template>
- <div class="dropdown-footer">
- <ul class="dropdown-footer-list">
- <li>
- <a href="#" class="dropdown-toggle-page"> {{ createLabelTitle }} </a>
- </li>
- <li>
- <a :href="labelsWebUrl" data-is-link="true" class="dropdown-external-link">
- {{ manageLabelsTitle }}
- </a>
- </li>
- </ul>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
deleted file mode 100644
index 4f505b9e678..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_header.vue
+++ /dev/null
@@ -1,22 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
-};
-</script>
-
-<template>
- <div class="dropdown-title gl-display-flex gl-justify-content-center">
- <span class="gl-ml-auto">{{ __('Assign labels') }}</span>
- <button
- :aria-label="__('Close')"
- type="button"
- class="dropdown-title-button dropdown-menu-close gl-ml-auto"
- >
- <gl-icon name="close" class="dropdown-menu-close-icon" />
- </button>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
deleted file mode 100644
index 6222dfc5853..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue
+++ /dev/null
@@ -1,28 +0,0 @@
-<script>
-import { GlIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlIcon,
- },
-};
-</script>
-
-<template>
- <div class="dropdown-input">
- <input
- :placeholder="__('Search')"
- autocomplete="off"
- class="dropdown-input-field"
- type="search"
- />
- <gl-icon
- name="search"
- class="dropdown-input-search gl-absolute gl-top-3 gl-right-5 gl-text-gray-300 gl-pointer-events-none"
- />
- <gl-icon
- name="close"
- class="dropdown-input-clear js-dropdown-input-clear gl-absolute gl-top-3 gl-right-5 gl-text-gray-500"
- />
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
deleted file mode 100644
index 91cf5d6bef5..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_title.vue
+++ /dev/null
@@ -1,31 +0,0 @@
-<script>
-import { GlLoadingIcon } from '@gitlab/ui';
-
-export default {
- components: {
- GlLoadingIcon,
- },
- props: {
- canEdit: {
- type: Boolean,
- required: true,
- },
- },
-};
-</script>
-
-<template>
- <div class="title hide-collapsed gl-mb-3">
- {{ __('Labels') }}
- <template v-if="canEdit">
- <gl-loading-icon inline class="align-text-top block-loading" />
- <button
- type="button"
- class="edit-link btn btn-blank float-right js-sidebar-dropdown-toggle"
- data-qa-selector="labels_edit_button"
- >
- {{ __('Edit') }}
- </button>
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
deleted file mode 100644
index 71d7069dd57..00000000000
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value.vue
+++ /dev/null
@@ -1,65 +0,0 @@
-<script>
-import { GlLabel } from '@gitlab/ui';
-import { isScopedLabel } from '~/lib/utils/common_utils';
-
-export default {
- components: {
- GlLabel,
- },
- props: {
- labels: {
- type: Array,
- required: true,
- },
- labelFilterBasePath: {
- type: String,
- required: true,
- },
- enableScopedLabels: {
- type: Boolean,
- required: false,
- default: false,
- },
- },
- computed: {
- isEmpty() {
- return this.labels.length === 0;
- },
- },
- methods: {
- labelFilterUrl(label) {
- return `${this.labelFilterBasePath}?label_name[]=${encodeURIComponent(label.title)}`;
- },
- scopedLabelsDescription({ description = '' }) {
- return `<span class="font-weight-bold scoped-label-tooltip-title">Scoped label</span><br />${description}`;
- },
- showScopedLabels(label) {
- return this.enableScopedLabels && isScopedLabel(label);
- },
- },
-};
-</script>
-
-<template>
- <div
- :class="{
- 'has-labels': !isEmpty,
- }"
- class="hide-collapsed value issuable-show-labels js-value"
- >
- <span v-if="isEmpty" class="text-secondary">
- <slot>{{ __('None') }}</slot>
- </span>
-
- <template v-for="label in labels" v-else>
- <gl-label
- :key="label.id"
- :target="labelFilterUrl(label)"
- :background-color="label.color"
- :title="label.title"
- :description="label.description"
- :scoped="showScopedLabels(label)"
- />
- </template>
- </div>
-</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
index 5d1663bc1fd..813de528c0b 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue
@@ -23,17 +23,18 @@ export default {
</script>
<template>
- <div class="title hide-collapsed gl-mb-3">
+ <div class="hide-collapsed gl-line-height-20 gl-mb-2 gl-text-gray-900">
{{ __('Labels') }}
<template v-if="allowLabelEdit">
<gl-loading-icon v-show="labelsSelectInProgress" inline />
<gl-button
variant="link"
- class="float-right js-sidebar-dropdown-toggle"
+ class="float-right gl-text-gray-900! gl-hover-text-blue-800! js-sidebar-dropdown-toggle"
data-qa-selector="labels_edit_button"
@click="toggleDropdownContents"
- >{{ __('Edit') }}</gl-button
>
+ {{ __('Edit') }}
+ </gl-button>
</template>
</div>
</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
index 122250d1ce7..122250d1ce7 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
index a4462895f6a..87af3ffc52c 100644
--- a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue
@@ -5,13 +5,12 @@ import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
-import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
-
import { DropdownVariant } from './constants';
import DropdownButton from './dropdown_button.vue';
import DropdownContents from './dropdown_contents.vue';
import DropdownTitle from './dropdown_title.vue';
import DropdownValue from './dropdown_value.vue';
+import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import labelsSelectModule from './store';
Vue.use(Vuex);
@@ -61,6 +60,11 @@ export default {
required: false,
default: () => [],
},
+ hideCollapsedView: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
labelsSelectInProgress: {
type: Boolean,
required: false,
@@ -294,6 +298,7 @@ export default {
>
<template v-if="isDropdownVariantSidebar">
<dropdown-value-collapsed
+ v-if="!hideCollapsedView"
ref="dropdownButtonCollapsed"
:labels="selectedLabels"
@onValueClick="handleCollapsedValueClick"
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
new file mode 100644
index 00000000000..00c54313292
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/constants.js
@@ -0,0 +1,5 @@
+export const DropdownVariant = {
+ Sidebar: 'sidebar',
+ Standalone: 'standalone',
+ Embedded: 'embedded',
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue
new file mode 100644
index 00000000000..60111210f5d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue
@@ -0,0 +1,42 @@
+<script>
+import { GlButton, GlIcon } from '@gitlab/ui';
+import { mapActions, mapGetters } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlIcon,
+ },
+ computed: {
+ ...mapGetters([
+ 'dropdownButtonText',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ handleButtonClick(e) {
+ if (this.isDropdownVariantStandalone || this.isDropdownVariantEmbedded) {
+ this.toggleDropdownContents();
+ }
+
+ if (this.isDropdownVariantStandalone) {
+ e.stopPropagation();
+ }
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-button
+ class="labels-select-dropdown-button js-dropdown-button w-100 text-left"
+ @click="handleButtonClick"
+ >
+ <span class="dropdown-toggle-text gl-pointer-events-none flex-fill">
+ {{ dropdownButtonText }}
+ </span>
+ <gl-icon name="chevron-down" class="gl-pointer-events-none float-right" />
+ </gl-button>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
new file mode 100644
index 00000000000..d80b66fd9be
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue
@@ -0,0 +1,44 @@
+<script>
+import { mapGetters, mapState } from 'vuex';
+
+import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
+import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
+
+export default {
+ components: {
+ DropdownContentsLabelsView,
+ DropdownContentsCreateView,
+ },
+ props: {
+ renderOnTop: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState(['showDropdownContentsCreateView']),
+ ...mapGetters(['isDropdownVariantSidebar']),
+ dropdownContentsView() {
+ if (this.showDropdownContentsCreateView) {
+ return 'dropdown-contents-create-view';
+ }
+ return 'dropdown-contents-labels-view';
+ },
+ directionStyle() {
+ const bottom = this.isDropdownVariantSidebar ? '3rem' : '2rem';
+ return this.renderOnTop ? { bottom } : {};
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-dropdown-contents gl-w-full gl-my-2 gl-py-3 gl-rounded-base gl-absolute"
+ data-qa-selector="labels_dropdown_content"
+ :style="directionStyle"
+ >
+ <component :is="dropdownContentsView" />
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
new file mode 100644
index 00000000000..f8cc981ba3d
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue
@@ -0,0 +1,119 @@
+<script>
+import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlFormInput,
+ GlLink,
+ GlLoadingIcon,
+ },
+ directives: {
+ GlTooltip: GlTooltipDirective,
+ },
+ data() {
+ return {
+ labelTitle: '',
+ selectedColor: '',
+ };
+ },
+ computed: {
+ ...mapState(['labelsCreateTitle', 'labelCreateInProgress']),
+ 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] }));
+ },
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents', 'toggleDropdownContentsCreateView', 'createLabel']),
+ getColorCode(color) {
+ return Object.keys(color).pop();
+ },
+ getColorName(color) {
+ return Object.values(color).pop();
+ },
+ handleColorClick(color) {
+ this.selectedColor = this.getColorCode(color);
+ },
+ handleCreateClick() {
+ this.createLabel({
+ title: this.labelTitle,
+ color: this.selectedColor,
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div class="labels-select-contents-create js-labels-create">
+ <div class="dropdown-title d-flex align-items-center pt-0 pb-2">
+ <gl-button
+ :aria-label="__('Go back')"
+ variant="link"
+ size="small"
+ class="js-btn-back dropdown-header-button p-0"
+ icon="arrow-left"
+ @click="toggleDropdownContentsCreateView"
+ />
+ <span class="flex-grow-1">{{ labelsCreateTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button p-0"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <div class="dropdown-input">
+ <gl-form-input
+ v-model.trim="labelTitle"
+ :placeholder="__('Name new label')"
+ :autofocus="true"
+ />
+ </div>
+ <div class="dropdown-content px-2">
+ <div class="suggest-colors suggest-colors-dropdown mt-0 mb-2">
+ <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 position-relative position-relative d-inline-block"
+ :style="{ backgroundColor: selectedColor }"
+ ></span>
+ <gl-form-input
+ v-model.trim="selectedColor"
+ class="gl-rounded-top-left-none gl-rounded-bottom-left-none"
+ :placeholder="__('Use custom color #FF0000')"
+ />
+ </div>
+ </div>
+ <div class="dropdown-actions clearfix pt-2 px-2">
+ <gl-button
+ :disabled="disableCreate"
+ category="primary"
+ variant="success"
+ class="float-left d-flex align-items-center"
+ @click="handleCreateClick"
+ >
+ <gl-loading-icon v-show="labelCreateInProgress" :inline="true" class="mr-1" />
+ {{ __('Create') }}
+ </gl-button>
+ <gl-button class="float-right js-btn-cancel-create" @click="toggleDropdownContentsCreateView">
+ {{ __('Cancel') }}
+ </gl-button>
+ </div>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
new file mode 100644
index 00000000000..86788a84260
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue
@@ -0,0 +1,221 @@
+<script>
+import {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlButton,
+ GlSearchBoxByType,
+ GlLink,
+} from '@gitlab/ui';
+import fuzzaldrinPlus from 'fuzzaldrin-plus';
+import { mapState, mapGetters, mapActions } from 'vuex';
+
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+
+import LabelItem from './label_item.vue';
+
+export default {
+ components: {
+ GlIntersectionObserver,
+ GlLoadingIcon,
+ GlButton,
+ GlSearchBoxByType,
+ GlLink,
+ LabelItem,
+ },
+ data() {
+ return {
+ searchKey: '',
+ currentHighlightItem: -1,
+ };
+ },
+ computed: {
+ ...mapState([
+ 'allowLabelCreate',
+ 'allowMultiselect',
+ 'labelsManagePath',
+ 'labels',
+ 'labelsFetchInProgress',
+ 'labelsListTitle',
+ 'footerCreateLabelTitle',
+ 'footerManageLabelTitle',
+ ]),
+ ...mapGetters(['selectedLabelsList', 'isDropdownVariantSidebar', 'isDropdownVariantEmbedded']),
+ 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;
+ },
+ },
+ watch: {
+ searchKey(value) {
+ // When there is search string present
+ // and there are matching results,
+ // highlight first item by default.
+ if (value && this.visibleLabels.length) {
+ this.currentHighlightItem = 0;
+ }
+ },
+ },
+ methods: {
+ ...mapActions([
+ 'toggleDropdownContents',
+ 'toggleDropdownContentsCreateView',
+ 'fetchLabels',
+ 'receiveLabelsSuccess',
+ 'updateSelectedLabels',
+ 'toggleDropdownContents',
+ ]),
+ isLabelSelected(label) {
+ return this.selectedLabelsList.includes(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;
+ }
+ }
+ },
+ handleComponentAppear() {
+ // We can avoid putting `catch` block here
+ // as failure is handled within actions.js already.
+ return this.fetchLabels().then(() => {
+ this.$refs.searchInput.focusInput();
+ });
+ },
+ /**
+ * We want to remove loaded labels to ensure component
+ * fetches fresh set of labels every time when shown.
+ */
+ handleComponentDisappear() {
+ this.receiveLabelsSuccess([]);
+ },
+ handleCreateLabelClick() {
+ this.receiveLabelsSuccess([]);
+ this.toggleDropdownContentsCreateView();
+ },
+ /**
+ * This method enables keyboard navigation support for
+ * the dropdown.
+ */
+ handleKeyDown(e) {
+ if (e.keyCode === UP_KEY_CODE && this.currentHighlightItem > 0) {
+ this.currentHighlightItem -= 1;
+ } else if (
+ e.keyCode === DOWN_KEY_CODE &&
+ this.currentHighlightItem < this.visibleLabels.length - 1
+ ) {
+ this.currentHighlightItem += 1;
+ } else if (e.keyCode === ENTER_KEY_CODE && this.currentHighlightItem > -1) {
+ this.updateSelectedLabels([this.visibleLabels[this.currentHighlightItem]]);
+ this.searchKey = '';
+ } else if (e.keyCode === ESC_KEY_CODE) {
+ this.toggleDropdownContents();
+ }
+
+ if (e.keyCode !== ESC_KEY_CODE) {
+ // Scroll the list only after highlighting
+ // styles are rendered completely.
+ this.$nextTick(() => {
+ this.scrollIntoViewIfNeeded();
+ });
+ }
+ },
+ handleLabelClick(label) {
+ this.updateSelectedLabels([label]);
+ if (!this.allowMultiselect) this.toggleDropdownContents();
+ },
+ },
+};
+</script>
+
+<template>
+ <gl-intersection-observer @appear="handleComponentAppear" @disappear="handleComponentDisappear">
+ <div class="labels-select-contents-list js-labels-list" @keydown="handleKeyDown">
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
+ data-testid="dropdown-title"
+ >
+ <span class="flex-grow-1">{{ labelsListTitle }}</span>
+ <gl-button
+ :aria-label="__('Close')"
+ variant="link"
+ size="small"
+ class="dropdown-header-button gl-p-0!"
+ icon="close"
+ @click="toggleDropdownContents"
+ />
+ </div>
+ <div class="dropdown-input" @click.stop="() => {}">
+ <gl-search-box-by-type
+ ref="searchInput"
+ v-model="searchKey"
+ :disabled="labelsFetchInProgress"
+ data-qa-selector="dropdown_input_field"
+ />
+ </div>
+ <div ref="labelsListContainer" class="dropdown-content" data-testid="dropdown-content">
+ <gl-loading-icon
+ v-if="labelsFetchInProgress"
+ class="labels-fetch-loading gl-align-items-center w-100 h-100"
+ size="md"
+ />
+ <ul v-else class="list-unstyled gl-mb-0 gl-word-break-word">
+ <label-item
+ v-for="(label, index) in visibleLabels"
+ :key="label.id"
+ :label="label"
+ :is-label-set="label.set"
+ :highlight="index === currentHighlightItem"
+ @clickLabel="handleLabelClick(label)"
+ />
+ <li v-show="showNoMatchingResultsMessage" class="gl-p-3 gl-text-center">
+ {{ __('No matching results') }}
+ </li>
+ </ul>
+ </div>
+ <div
+ v-if="isDropdownVariantSidebar || isDropdownVariantEmbedded"
+ class="dropdown-footer"
+ data-testid="dropdown-footer"
+ >
+ <ul class="list-unstyled">
+ <li v-if="allowLabelCreate">
+ <gl-link
+ class="gl-display-flex w-100 flex-row text-break-word label-item"
+ @click="handleCreateLabelClick"
+ >
+ {{ footerCreateLabelTitle }}
+ </gl-link>
+ </li>
+ <li>
+ <gl-link
+ :href="labelsManagePath"
+ class="gl-display-flex flex-row text-break-word label-item"
+ >
+ {{ footerManageLabelTitle }}
+ </gl-link>
+ </li>
+ </ul>
+ </div>
+ </div>
+ </gl-intersection-observer>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
new file mode 100644
index 00000000000..5d1663bc1fd
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue
@@ -0,0 +1,39 @@
+<script>
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { mapState, mapActions } from 'vuex';
+
+export default {
+ components: {
+ GlButton,
+ GlLoadingIcon,
+ },
+ props: {
+ labelsSelectInProgress: {
+ type: Boolean,
+ required: true,
+ },
+ },
+ computed: {
+ ...mapState(['allowLabelEdit', 'labelsFetchInProgress']),
+ },
+ methods: {
+ ...mapActions(['toggleDropdownContents']),
+ },
+};
+</script>
+
+<template>
+ <div class="title hide-collapsed gl-mb-3">
+ {{ __('Labels') }}
+ <template v-if="allowLabelEdit">
+ <gl-loading-icon v-show="labelsSelectInProgress" inline />
+ <gl-button
+ variant="link"
+ class="float-right js-sidebar-dropdown-toggle"
+ data-qa-selector="labels_edit_button"
+ @click="toggleDropdownContents"
+ >{{ __('Edit') }}</gl-button
+ >
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
new file mode 100644
index 00000000000..46ccb9470e5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue
@@ -0,0 +1,67 @@
+<script>
+import { GlLabel } from '@gitlab/ui';
+import { mapState } from 'vuex';
+
+import { isScopedLabel } from '~/lib/utils/common_utils';
+
+export default {
+ components: {
+ GlLabel,
+ },
+ props: {
+ disableLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ computed: {
+ ...mapState([
+ 'selectedLabels',
+ 'allowLabelRemove',
+ 'allowScopedLabels',
+ 'labelsFilterBasePath',
+ 'labelsFilterParam',
+ ]),
+ },
+ methods: {
+ labelFilterUrl(label) {
+ return `${this.labelsFilterBasePath}?${this.labelsFilterParam}[]=${encodeURIComponent(
+ label.title,
+ )}`;
+ },
+ scopedLabel(label) {
+ return this.allowScopedLabels && isScopedLabel(label);
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ :class="{
+ 'has-labels': selectedLabels.length,
+ }"
+ class="hide-collapsed value issuable-show-labels js-value"
+ >
+ <span v-if="!selectedLabels.length" class="text-secondary">
+ <slot></slot>
+ </span>
+ <template v-for="label in selectedLabels" v-else>
+ <gl-label
+ :key="label.id"
+ 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="$emit('onLabelRemove', label.id)"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
new file mode 100644
index 00000000000..e8fdf4bb0c2
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/label_item.vue
@@ -0,0 +1,82 @@
+<script>
+import { GlLink, GlIcon } from '@gitlab/ui';
+
+export default {
+ functional: true,
+ props: {
+ label: {
+ type: Object,
+ required: true,
+ },
+ isLabelSet: {
+ type: Boolean,
+ required: true,
+ },
+ highlight: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ render(h, { props, listeners }) {
+ const { label, highlight, isLabelSet } = props;
+
+ const labelColorBox = h('span', {
+ class: 'dropdown-label-box gl-flex-shrink-0 gl-top-0 gl-mr-3',
+ style: {
+ backgroundColor: label.color,
+ },
+ attrs: {
+ 'data-testid': 'label-color-box',
+ },
+ });
+
+ const checkedIcon = h(GlIcon, {
+ class: {
+ 'gl-mr-3 gl-flex-shrink-0': true,
+ hidden: !isLabelSet,
+ },
+ props: {
+ name: 'mobile-issue-close',
+ },
+ });
+
+ const noIcon = h('span', {
+ class: {
+ 'gl-mr-5 gl-pr-3': true,
+ hidden: isLabelSet,
+ },
+ attrs: {
+ 'data-testid': 'no-icon',
+ },
+ });
+
+ const labelTitle = h('span', label.title);
+
+ const labelLink = h(
+ GlLink,
+ {
+ class: 'gl-display-flex gl-align-items-center label-item gl-text-black-normal',
+ on: {
+ click: () => {
+ listeners.clickLabel(label);
+ },
+ },
+ },
+ [noIcon, checkedIcon, labelColorBox, labelTitle],
+ );
+
+ return h(
+ 'li',
+ {
+ class: {
+ 'gl-display-block': true,
+ 'gl-text-left': true,
+ 'is-focused': highlight,
+ },
+ },
+ [labelLink],
+ );
+ },
+};
+</script>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
new file mode 100644
index 00000000000..bf30e3cfac5
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue
@@ -0,0 +1,327 @@
+<script>
+import $ from 'jquery';
+import Vue from 'vue';
+import Vuex, { mapState, mapActions, mapGetters } from 'vuex';
+import { isInViewport } from '~/lib/utils/common_utils';
+import { __ } from '~/locale';
+
+import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue';
+
+import { DropdownVariant } from './constants';
+import DropdownButton from './dropdown_button.vue';
+import DropdownContents from './dropdown_contents.vue';
+import DropdownTitle from './dropdown_title.vue';
+import DropdownValue from './dropdown_value.vue';
+import labelsSelectModule from './store';
+
+Vue.use(Vuex);
+
+export default {
+ store: new Vuex.Store(labelsSelectModule()),
+ components: {
+ DropdownTitle,
+ DropdownValue,
+ DropdownButton,
+ DropdownContents,
+ DropdownValueCollapsed,
+ },
+ props: {
+ allowLabelRemove: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowLabelEdit: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowLabelCreate: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowMultiselect: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ allowScopedLabels: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ variant: {
+ type: String,
+ required: false,
+ default: DropdownVariant.Sidebar,
+ },
+ selectedLabels: {
+ type: Array,
+ required: false,
+ default: () => [],
+ },
+ labelsSelectInProgress: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ labelsFetchPath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ labelsManagePath: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ 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'),
+ },
+ isEditing: {
+ type: Boolean,
+ required: false,
+ default: false,
+ },
+ },
+ data() {
+ return {
+ contentIsOnViewport: true,
+ };
+ },
+ computed: {
+ ...mapState(['showDropdownButton', 'showDropdownContents']),
+ ...mapGetters([
+ 'isDropdownVariantSidebar',
+ 'isDropdownVariantStandalone',
+ 'isDropdownVariantEmbedded',
+ ]),
+ dropdownButtonVisible() {
+ return this.isDropdownVariantSidebar ? this.showDropdownButton : true;
+ },
+ },
+ watch: {
+ selectedLabels(selectedLabels) {
+ this.setInitialState({
+ selectedLabels,
+ });
+ },
+ showDropdownContents(showDropdownContents) {
+ this.setContentIsOnViewport(showDropdownContents);
+ },
+ isEditing(newVal) {
+ if (newVal) {
+ this.toggleDropdownContents();
+ }
+ },
+ },
+ mounted() {
+ this.setInitialState({
+ variant: this.variant,
+ allowLabelRemove: this.allowLabelRemove,
+ allowLabelEdit: this.allowLabelEdit,
+ allowLabelCreate: this.allowLabelCreate,
+ allowMultiselect: this.allowMultiselect,
+ allowScopedLabels: this.allowScopedLabels,
+ dropdownButtonText: this.dropdownButtonText,
+ selectedLabels: this.selectedLabels,
+ labelsFetchPath: this.labelsFetchPath,
+ labelsManagePath: this.labelsManagePath,
+ labelsFilterBasePath: this.labelsFilterBasePath,
+ labelsFilterParam: this.labelsFilterParam,
+ labelsListTitle: this.labelsListTitle,
+ labelsCreateTitle: this.labelsCreateTitle,
+ footerCreateLabelTitle: this.footerCreateLabelTitle,
+ footerManageLabelTitle: this.footerManageLabelTitle,
+ });
+
+ this.$store.subscribeAction({
+ after: this.handleVuexActionDispatch,
+ });
+
+ document.addEventListener('mousedown', this.handleDocumentMousedown);
+ document.addEventListener('click', this.handleDocumentClick);
+ },
+ beforeDestroy() {
+ document.removeEventListener('mousedown', this.handleDocumentMousedown);
+ document.removeEventListener('click', this.handleDocumentClick);
+ },
+ methods: {
+ ...mapActions(['setInitialState', 'toggleDropdownContents']),
+ /**
+ * This method differentiates between
+ * dispatched actions and calls necessary method.
+ */
+ handleVuexActionDispatch(action, state) {
+ if (
+ action.type === 'toggleDropdownContents' &&
+ !state.showDropdownButton &&
+ !state.showDropdownContents
+ ) {
+ let filterFn = (label) => label.touched;
+ if (this.isDropdownVariantEmbedded) {
+ filterFn = (label) => label.set;
+ }
+ this.handleDropdownClose(state.labels.filter(filterFn));
+ }
+ },
+ /**
+ * This method stores a mousedown event's target.
+ * Required by the click listener because the click
+ * event itself has no reference to this element.
+ */
+ handleDocumentMousedown({ target }) {
+ this.mousedownTarget = target;
+ },
+ /**
+ * This method listens for document-wide click event
+ * and toggle dropdown if user clicks anywhere outside
+ * the dropdown while dropdown is visible.
+ */
+ handleDocumentClick({ target }) {
+ // We also perform the toggle exception check for the
+ // last mousedown event's target to avoid hiding the
+ // box when the mousedown happened inside the box and
+ // only the mouseup did not.
+ if (
+ this.showDropdownContents &&
+ !this.preventDropdownToggleOnClick(target) &&
+ !this.preventDropdownToggleOnClick(this.mousedownTarget)
+ ) {
+ this.toggleDropdownContents();
+ }
+ },
+ /**
+ * This method checks whether a given click target
+ * should prevent the dropdown from being toggled.
+ */
+ preventDropdownToggleOnClick(target) {
+ // This approach of element detection is needed
+ // as the dropdown wrapper is not using `GlDropdown` as
+ // it will also require us to use `BDropdownForm`
+ // which is yet to be implemented in GitLab UI.
+ const hasExceptionClass = [
+ 'js-dropdown-button',
+ 'js-btn-cancel-create',
+ 'js-sidebar-dropdown-toggle',
+ ].some(
+ (className) =>
+ target?.classList.contains(className) ||
+ target?.parentElement?.classList.contains(className),
+ );
+
+ const hasExceptionParent = ['.js-btn-back', '.js-labels-list'].some(
+ (className) => $(target).parents(className).length,
+ );
+
+ const isInDropdownButtonCollapsed = this.$refs.dropdownButtonCollapsed?.$el.contains(target);
+
+ const isInDropdownContents = this.$refs.dropdownContents?.$el.contains(target);
+
+ return (
+ hasExceptionClass ||
+ hasExceptionParent ||
+ isInDropdownButtonCollapsed ||
+ isInDropdownContents
+ );
+ },
+ handleDropdownClose(labels) {
+ // Only emit label updates if there are any labels to update
+ // on UI.
+ if (labels.length) this.$emit('updateSelectedLabels', labels);
+ this.$emit('onDropdownClose');
+ },
+ handleCollapsedValueClick() {
+ this.$emit('toggleCollapse');
+ },
+ setContentIsOnViewport(showDropdownContents) {
+ if (!showDropdownContents) {
+ this.contentIsOnViewport = true;
+
+ return;
+ }
+
+ this.$nextTick(() => {
+ if (this.$refs.dropdownContents) {
+ this.contentIsOnViewport = isInViewport(this.$refs.dropdownContents.$el);
+ }
+ });
+ },
+ },
+};
+</script>
+
+<template>
+ <div
+ class="labels-select-wrapper position-relative"
+ :class="{
+ 'is-standalone': isDropdownVariantStandalone,
+ 'is-embedded': isDropdownVariantEmbedded,
+ }"
+ >
+ <template v-if="isDropdownVariantSidebar">
+ <dropdown-value-collapsed
+ ref="dropdownButtonCollapsed"
+ :labels="selectedLabels"
+ @onValueClick="handleCollapsedValueClick"
+ />
+ <dropdown-title
+ :allow-label-edit="allowLabelEdit"
+ :labels-select-in-progress="labelsSelectInProgress"
+ />
+ <dropdown-value
+ :disable-labels="labelsSelectInProgress"
+ @onLabelRemove="$emit('onLabelRemove', $event)"
+ >
+ <slot></slot>
+ </dropdown-value>
+ <dropdown-button v-show="dropdownButtonVisible" class="gl-mt-2" />
+ <dropdown-contents
+ v-show="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
+ />
+ </template>
+ <template v-if="isDropdownVariantStandalone || isDropdownVariantEmbedded">
+ <dropdown-button v-show="dropdownButtonVisible" />
+ <dropdown-contents
+ v-if="dropdownButtonVisible && showDropdownContents"
+ ref="dropdownContents"
+ :render-on-top="!contentIsOnViewport"
+ />
+ </template>
+ </div>
+</template>
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
new file mode 100644
index 00000000000..89f96ab916b
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/actions.js
@@ -0,0 +1,58 @@
+import { deprecatedCreateFlash as flash } from '~/flash';
+import axios from '~/lib/utils/axios_utils';
+import { __ } from '~/locale';
+import * as types from './mutation_types';
+
+export const setInitialState = ({ commit }, props) => commit(types.SET_INITIAL_STATE, props);
+
+export const toggleDropdownButton = ({ commit }) => commit(types.TOGGLE_DROPDOWN_BUTTON);
+export const toggleDropdownContents = ({ commit }) => commit(types.TOGGLE_DROPDOWN_CONTENTS);
+
+export const toggleDropdownContentsCreateView = ({ commit }) =>
+ commit(types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW);
+
+export const requestLabels = ({ commit }) => commit(types.REQUEST_LABELS);
+export const receiveLabelsSuccess = ({ commit }, labels) =>
+ commit(types.RECEIVE_SET_LABELS_SUCCESS, labels);
+export const receiveLabelsFailure = ({ commit }) => {
+ commit(types.RECEIVE_SET_LABELS_FAILURE);
+ flash(__('Error fetching labels.'));
+};
+export const fetchLabels = ({ state, dispatch }) => {
+ dispatch('requestLabels');
+ return axios
+ .get(state.labelsFetchPath)
+ .then(({ data }) => {
+ dispatch('receiveLabelsSuccess', data);
+ })
+ .catch(() => dispatch('receiveLabelsFailure'));
+};
+
+export const requestCreateLabel = ({ commit }) => commit(types.REQUEST_CREATE_LABEL);
+export const receiveCreateLabelSuccess = ({ commit }) => commit(types.RECEIVE_CREATE_LABEL_SUCCESS);
+export const receiveCreateLabelFailure = ({ commit }) => {
+ commit(types.RECEIVE_CREATE_LABEL_FAILURE);
+ flash(__('Error creating label.'));
+};
+export const createLabel = ({ state, dispatch }, label) => {
+ dispatch('requestCreateLabel');
+ axios
+ .post(state.labelsManagePath, {
+ label,
+ })
+ .then(({ data }) => {
+ if (data.id) {
+ dispatch('receiveCreateLabelSuccess');
+ dispatch('toggleDropdownContentsCreateView');
+ } else {
+ // eslint-disable-next-line @gitlab/require-i18n-strings
+ throw new Error('Error Creating Label');
+ }
+ })
+ .catch(() => {
+ dispatch('receiveCreateLabelFailure');
+ });
+};
+
+export const updateSelectedLabels = ({ commit }, labels) =>
+ commit(types.UPDATE_SELECTED_LABELS, { labels });
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js
new file mode 100644
index 00000000000..d14f96720b7
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/getters.js
@@ -0,0 +1,52 @@
+import { __, s__, sprintf } from '~/locale';
+import { DropdownVariant } from '../constants';
+
+/**
+ * Returns string representing current labels
+ * selection on dropdown button.
+ *
+ * @param {object} state
+ */
+export const dropdownButtonText = (state, getters) => {
+ const selectedLabels = getters.isDropdownVariantSidebar
+ ? state.labels.filter((label) => label.set)
+ : state.selectedLabels;
+
+ if (!selectedLabels.length) {
+ return state.dropdownButtonText || __('Label');
+ } else if (selectedLabels.length > 1) {
+ return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
+ firstLabelName: selectedLabels[0].title,
+ remainingLabelCount: selectedLabels.length - 1,
+ });
+ }
+ return selectedLabels[0].title;
+};
+
+/**
+ * Returns array containing only label IDs from
+ * selectedLabels array.
+ * @param {object} state
+ */
+export const selectedLabelsList = (state) => state.selectedLabels.map((label) => label.id);
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `sidebar`
+ * @param {object} state
+ */
+export const isDropdownVariantSidebar = (state) => state.variant === DropdownVariant.Sidebar;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `standalone`
+ * @param {object} state
+ */
+export const isDropdownVariantStandalone = (state) => state.variant === DropdownVariant.Standalone;
+
+/**
+ * Returns boolean representing whether dropdown variant
+ * is `embedded`
+ * @param {object} state
+ */
+export const isDropdownVariantEmbedded = (state) => state.variant === DropdownVariant.Embedded;
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js
new file mode 100644
index 00000000000..5f61cb732c8
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/index.js
@@ -0,0 +1,12 @@
+import * as actions from './actions';
+import * as getters from './getters';
+import mutations from './mutations';
+import state from './state';
+
+export default () => ({
+ namespaced: true,
+ state: state(),
+ actions,
+ getters,
+ mutations,
+});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
new file mode 100644
index 00000000000..2e044dc3b3c
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutation_types.js
@@ -0,0 +1,20 @@
+export const SET_INITIAL_STATE = 'SET_INITIAL_STATE';
+
+export const REQUEST_LABELS = 'REQUEST_LABELS';
+export const RECEIVE_LABELS_SUCCESS = 'RECEIVE_LABELS_SUCCESS';
+export const RECEIVE_LABELS_FAILURE = 'RECEIVE_LABELS_FAILURE';
+
+export const REQUEST_SET_LABELS = 'REQUEST_SET_LABELS';
+export const RECEIVE_SET_LABELS_SUCCESS = 'RECEIVE_SET_LABELS_SUCCESS';
+export const RECEIVE_SET_LABELS_FAILURE = 'RECEIVE_SET_LABELS_FAILURE';
+
+export const REQUEST_CREATE_LABEL = 'REQUEST_CREATE_LABEL';
+export const RECEIVE_CREATE_LABEL_SUCCESS = 'RECEIVE_CREATE_LABEL_SUCCESS';
+export const RECEIVE_CREATE_LABEL_FAILURE = 'RECEIVE_CREATE_LABEL_FAILURE';
+
+export const TOGGLE_DROPDOWN_BUTTON = 'TOGGLE_DROPDOWN_VISIBILITY';
+export const TOGGLE_DROPDOWN_CONTENTS = 'TOGGLE_DROPDOWN_CONTENTS';
+
+export const UPDATE_SELECTED_LABELS = 'UPDATE_SELECTED_LABELS';
+
+export const TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW = 'TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW';
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
new file mode 100644
index 00000000000..55716e1105e
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/mutations.js
@@ -0,0 +1,70 @@
+import { DropdownVariant } from '../constants';
+import * as types from './mutation_types';
+
+export default {
+ [types.SET_INITIAL_STATE](state, props) {
+ Object.assign(state, { ...props });
+ },
+
+ [types.TOGGLE_DROPDOWN_BUTTON](state) {
+ state.showDropdownButton = !state.showDropdownButton;
+ },
+
+ [types.TOGGLE_DROPDOWN_CONTENTS](state) {
+ if (state.variant === DropdownVariant.Sidebar) {
+ state.showDropdownButton = !state.showDropdownButton;
+ }
+ state.showDropdownContents = !state.showDropdownContents;
+ // Ensure that Create View is hidden by default
+ // when dropdown contents are revealed.
+ if (state.showDropdownContents) {
+ state.showDropdownContentsCreateView = false;
+ }
+ },
+
+ [types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state) {
+ state.showDropdownContentsCreateView = !state.showDropdownContentsCreateView;
+ },
+
+ [types.REQUEST_LABELS](state) {
+ state.labelsFetchInProgress = true;
+ },
+ [types.RECEIVE_SET_LABELS_SUCCESS](state, labels) {
+ // Iterate over every label and add a `set` prop
+ // to determine whether it is already a part of
+ // selectedLabels array.
+ const selectedLabelIds = state.selectedLabels.map((label) => label.id);
+ state.labelsFetchInProgress = false;
+ state.labels = labels.reduce((allLabels, label) => {
+ allLabels.push({
+ ...label,
+ set: selectedLabelIds.includes(label.id),
+ });
+ return allLabels;
+ }, []);
+ },
+ [types.RECEIVE_SET_LABELS_FAILURE](state) {
+ state.labelsFetchInProgress = false;
+ },
+
+ [types.REQUEST_CREATE_LABEL](state) {
+ state.labelCreateInProgress = true;
+ },
+ [types.RECEIVE_CREATE_LABEL_SUCCESS](state) {
+ state.labelCreateInProgress = false;
+ },
+ [types.RECEIVE_CREATE_LABEL_FAILURE](state) {
+ state.labelCreateInProgress = false;
+ },
+
+ [types.UPDATE_SELECTED_LABELS](state, { labels }) {
+ // Find the label to update from all the labels
+ // and change `set` prop value to represent their current state.
+ const labelId = labels.pop()?.id;
+ const candidateLabel = state.labels.find((label) => labelId === label.id);
+ if (candidateLabel) {
+ candidateLabel.touched = true;
+ candidateLabel.set = !candidateLabel.set;
+ }
+ },
+};
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
new file mode 100644
index 00000000000..d66cfed4163
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/store/state.js
@@ -0,0 +1,29 @@
+export default () => ({
+ // Initial Data
+ labels: [],
+ selectedLabels: [],
+ labelsListTitle: '',
+ labelsCreateTitle: '',
+ footerCreateLabelTitle: '',
+ footerManageLabelTitle: '',
+ dropdownButtonText: '',
+
+ // Paths
+ namespace: '',
+ labelsFetchPath: '',
+ labelsFilterBasePath: '',
+
+ // UI Flags
+ variant: '',
+ allowLabelRemove: false,
+ allowLabelCreate: false,
+ allowLabelEdit: false,
+ allowScopedLabels: false,
+ allowMultiselect: false,
+ showDropdownButton: false,
+ showDropdownContents: false,
+ showDropdownContentsCreateView: false,
+ labelsFetchInProgress: false,
+ labelCreateInProgress: false,
+ selectedLabelsUpdated: false,
+});
diff --git a/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
new file mode 100644
index 00000000000..d99fc125012
--- /dev/null
+++ b/app/assets/javascripts/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql
@@ -0,0 +1,20 @@
+#import "~/graphql_shared/fragments/user.fragment.graphql"
+#import "~/graphql_shared/fragments/user_availability.fragment.graphql"
+
+query alertAssignees(
+ $domain: AlertManagementDomainFilter = threat_monitoring
+ $fullPath: ID!
+ $iid: String!
+) {
+ workspace: project(fullPath: $fullPath) {
+ issuable: alertManagementAlert(domain: $domain, iid: $iid) {
+ iid
+ assignees {
+ nodes {
+ ...User
+ ...UserAvailability
+ }
+ }
+ }
+ }
+}