diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 18:25:58 +0000 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /app/assets/javascripts/vue_shared/components/sidebar | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) | |
download | gitlab-ce-a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4.tar.gz |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'app/assets/javascripts/vue_shared/components/sidebar')
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="#" - > - - </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 + } + } + } + } +} |