Commit e0544cbf authored by David O'Regan's avatar David O'Regan

Merge branch '332262-introduce-a-feature-flag-for-labels-refactoring' into 'master'

Introduce a feature flag for labels widget [RUN ALL RSPEC] [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!62898
parents b960066b cb2e71a7
......@@ -10,6 +10,8 @@ import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_req
import { toLabelGid } from '~/sidebar/utils';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import glFeatureFlagMixin from '~/vue_shared/mixins/gl_feature_flags_mixin';
const mutationMap = {
[IssuableType.Issue]: {
......@@ -25,8 +27,10 @@ const mutationMap = {
export default {
components: {
LabelsSelect,
LabelsSelectWidget,
},
variant: DropdownVariant.Sidebar,
mixins: [glFeatureFlagMixin()],
inject: [
'allowLabelCreate',
'allowLabelEdit',
......@@ -135,7 +139,32 @@ export default {
</script>
<template>
<labels-select-widget
v-if="glFeatures.labelsWidget"
class="block labels js-labels-block"
:allow-label-remove="allowLabelEdit"
:allow-label-create="allowLabelCreate"
:allow-label-edit="allowLabelEdit"
:allow-multiselect="true"
:allow-scoped-labels="allowScopedLabels"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
:labels-fetch-path="labelsFetchPath"
:labels-filter-base-path="projectIssuesPath"
:labels-manage-path="labelsManagePath"
:labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels"
:variant="$options.sidebar"
data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
</labels-select-widget>
<labels-select
v-else
class="block labels js-labels-block"
:allow-label-remove="allowLabelEdit"
:allow-label-create="allowLabelCreate"
......
export const DropdownVariant = {
Sidebar: 'sidebar',
Standalone: 'standalone',
Embedded: 'embedded',
};
<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>
<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>
<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>
<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>
<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>
<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>
<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>
<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/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>
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 });
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;
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,
});
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';
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;
}
},
};
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,
});
......@@ -55,6 +55,7 @@ class Projects::IssuesController < Projects::ApplicationController
push_to_gon_attributes(:features, real_time_feature_flag, real_time_enabled)
push_frontend_feature_flag(:confidential_notes, @project, default_enabled: :yaml)
push_frontend_feature_flag(:issue_assignees_widget, @project, default_enabled: :yaml)
push_frontend_feature_flag(:labels_widget, @project, default_enabled: :yaml)
experiment(:invite_members_in_comment, namespace: @project.root_ancestor) do |experiment_instance|
experiment_instance.exclude! unless helpers.can_import_members?
......
---
name: labels_widget
introduced_by_url: https://gitlab.com/gitlab-org/gitlab/-/merge_requests/62898
rollout_issue_url: https://gitlab.com/gitlab-org/gitlab/-/issues/332327
milestone: '14.0'
type: development
group: group::project management
default_enabled: false
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment