Commit ce9b3e4f authored by Simon Knox's avatar Simon Knox

Merge branch...

Merge branch '339226-refactor-labels_select_root-to-be-able-to-use-on-embedded-and-standalone-dropdowns' into 'master'

Refactor labels_select_root for embedded and standalone dropdowns

See merge request gitlab-org/gitlab!69187
parents 0598fcf2 0e1b481c
...@@ -53,30 +53,32 @@ export default { ...@@ -53,30 +53,32 @@ export default {
handleDropdownClose() { handleDropdownClose() {
$(this.$el).trigger('hidden.gl.dropdown'); $(this.$el).trigger('hidden.gl.dropdown');
}, },
getUpdateVariables(dropdownLabels) { getUpdateVariables(labels) {
const currentLabelIds = this.selectedLabels.map((label) => label.id); let labelIds = [];
const dropdownLabelIds = dropdownLabels.map((label) => label.id);
const userAddedLabelIds = this.glFeatures.labelsWidget
? difference(dropdownLabelIds, currentLabelIds)
: dropdownLabels.filter((label) => label.set).map((label) => label.id);
const userRemovedLabelIds = this.glFeatures.labelsWidget
? difference(currentLabelIds, dropdownLabelIds)
: dropdownLabels.filter((label) => !label.set).map((label) => label.id);
const labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds); if (this.glFeatures.labelsWidget) {
labelIds = labels.map(({ id }) => toLabelGid(id));
} else {
const currentLabelIds = this.selectedLabels.map((label) => label.id);
const userAddedLabelIds = labels.filter((label) => label.set).map((label) => label.id);
const userRemovedLabelIds = labels.filter((label) => !label.set).map((label) => label.id);
labelIds = difference(union(currentLabelIds, userAddedLabelIds), userRemovedLabelIds).map(
toLabelGid,
);
}
switch (this.issuableType) { switch (this.issuableType) {
case IssuableType.Issue: case IssuableType.Issue:
return { return {
addLabelIds: userAddedLabelIds,
iid: this.iid, iid: this.iid,
projectPath: this.projectPath, projectPath: this.projectPath,
removeLabelIds: userRemovedLabelIds, labelIds,
}; };
case IssuableType.MergeRequest: case IssuableType.MergeRequest:
return { return {
iid: this.iid, iid: this.iid,
labelIds: labelIds.map(toLabelGid), labelIds,
operationMode: MutationOperationMode.Replace, operationMode: MutationOperationMode.Replace,
projectPath: this.projectPath, projectPath: this.projectPath,
}; };
...@@ -152,8 +154,8 @@ export default { ...@@ -152,8 +154,8 @@ export default {
:labels-select-in-progress="isLabelsSelectInProgress" :labels-select-in-progress="isLabelsSelectInProgress"
:selected-labels="selectedLabels" :selected-labels="selectedLabels"
:variant="$options.variant" :variant="$options.variant"
:issuable-type="issuableType"
data-qa-selector="labels_block" data-qa-selector="labels_block"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove" @onLabelRemove="handleLabelRemove"
@updateSelectedLabels="handleUpdateSelectedLabels" @updateSelectedLabels="handleUpdateSelectedLabels"
> >
......
...@@ -31,6 +31,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs ...@@ -31,6 +31,10 @@ import updateIssueSubscriptionMutation from '~/sidebar/queries/update_issue_subs
import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql'; import mergeRequestMilestoneMutation from '~/sidebar/queries/update_merge_request_milestone.mutation.graphql';
import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql'; import updateMergeRequestSubscriptionMutation from '~/sidebar/queries/update_merge_request_subscription.mutation.graphql';
import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql'; import updateAlertAssigneesMutation from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
import epicLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_labels.query.graphql';
import groupLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/group_labels.query.graphql';
import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql'; import getAlertAssignees from '~/vue_shared/components/sidebar/queries/get_alert_assignees.query.graphql';
import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; import getIssueAssignees from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql';
import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; import issueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
...@@ -105,6 +109,17 @@ export const referenceQueries = { ...@@ -105,6 +109,17 @@ export const referenceQueries = {
}, },
}; };
export const labelsQueries = {
[IssuableType.Issue]: {
issuableQuery: issueLabelsQuery,
workspaceQuery: projectLabelsQuery,
},
[IssuableType.Epic]: {
issuableQuery: epicLabelsQuery,
workspaceQuery: groupLabelsQuery,
},
};
export const dateTypes = { export const dateTypes = {
start: 'startDate', start: 'startDate',
due: 'dueDate', due: 'dueDate',
......
...@@ -241,6 +241,7 @@ function mountMilestoneSelect() { ...@@ -241,6 +241,7 @@ function mountMilestoneSelect() {
export function mountSidebarLabels() { export function mountSidebarLabels() {
const el = document.querySelector('.js-sidebar-labels'); const el = document.querySelector('.js-sidebar-labels');
const { fullPath } = getSidebarOptions();
if (!el) { if (!el) {
return false; return false;
...@@ -251,6 +252,7 @@ export function mountSidebarLabels() { ...@@ -251,6 +252,7 @@ export function mountSidebarLabels() {
apolloProvider, apolloProvider,
provide: { provide: {
...el.dataset, ...el.dataset,
fullPath,
allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate), allowLabelCreate: parseBoolean(el.dataset.allowLabelCreate),
allowLabelEdit: parseBoolean(el.dataset.canEdit), allowLabelEdit: parseBoolean(el.dataset.canEdit),
allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels), allowScopedLabels: parseBoolean(el.dataset.allowScopedLabels),
......
<script> <script>
import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui'; import { GlButton, GlDropdown, GlDropdownItem, GlLink } from '@gitlab/ui';
import { __, s__, sprintf } from '~/locale';
import DropdownContentsCreateView from './dropdown_contents_create_view.vue'; import DropdownContentsCreateView from './dropdown_contents_create_view.vue';
import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue'; import DropdownContentsLabelsView from './dropdown_contents_labels_view.vue';
import { isDropdownVariantSidebar, isDropdownVariantEmbedded } from './utils'; import { isDropdownVariantStandalone } from './utils';
export default { export default {
components: { components: {
...@@ -48,10 +48,15 @@ export default { ...@@ -48,10 +48,15 @@ export default {
type: String, type: String,
required: true, required: true,
}, },
issuableType: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
showDropdownContentsCreateView: false, showDropdownContentsCreateView: false,
localSelectedLabels: [...this.selectedLabels],
}; };
}, },
computed: { computed: {
...@@ -64,28 +69,42 @@ export default { ...@@ -64,28 +69,42 @@ export default {
dropdownTitle() { dropdownTitle() {
return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle; return this.showDropdownContentsCreateView ? this.labelsCreateTitle : this.labelsListTitle;
}, },
buttonText() {
if (!this.localSelectedLabels.length) {
return this.dropdownButtonText || __('Label');
} else if (this.localSelectedLabels.length > 1) {
return sprintf(s__('LabelSelect|%{firstLabelName} +%{remainingLabelCount} more'), {
firstLabelName: this.localSelectedLabels[0].title,
remainingLabelCount: this.localSelectedLabels.length - 1,
});
}
return this.localSelectedLabels[0].title;
},
showDropdownFooter() { showDropdownFooter() {
return ( return !this.showDropdownContentsCreateView && !this.isStandalone;
!this.showDropdownContentsCreateView &&
(this.isDropdownVariantSidebar(this.variant) ||
this.isDropdownVariantEmbedded(this.variant))
);
}, },
isStandalone() {
return isDropdownVariantStandalone(this.variant);
},
},
mounted() {
this.$refs.dropdown.show();
}, },
methods: { methods: {
showDropdown() {
this.$refs.dropdown.show();
},
toggleDropdownContentsCreateView() { toggleDropdownContentsCreateView() {
this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView; this.showDropdownContentsCreateView = !this.showDropdownContentsCreateView;
}, },
toggleDropdownContent() { toggleDropdownContent() {
this.toggleDropdownContentsCreateView(); this.toggleDropdownContentsCreateView();
// Required to recalculate dropdown position as its size changes // Required to recalculate dropdown position as its size changes
this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate(); if (this.$refs.dropdown?.$refs.dropdown) {
this.$refs.dropdown.$refs.dropdown.$_popper.scheduleUpdate();
}
},
closeDropdown() {
this.$emit('setLabels', this.localSelectedLabels);
this.$refs.dropdown.hide();
}, },
isDropdownVariantSidebar,
isDropdownVariantEmbedded,
}, },
}; };
</script> </script>
...@@ -93,14 +112,16 @@ export default { ...@@ -93,14 +112,16 @@ export default {
<template> <template>
<gl-dropdown <gl-dropdown
ref="dropdown" ref="dropdown"
:text="dropdownButtonText" :text="buttonText"
class="gl-w-full gl-mt-2" class="gl-w-full gl-mt-2"
data-qa-selector="labels_dropdown_content" data-qa-selector="labels_dropdown_content"
@hide="$emit('setLabels', localSelectedLabels)"
> >
<template #header> <template #header>
<div <div
v-if="isDropdownVariantSidebar(variant) || isDropdownVariantEmbedded(variant)" v-if="!isStandalone"
class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!" class="dropdown-title gl-display-flex gl-align-items-center gl-pt-0 gl-pb-3!"
data-testid="dropdown-header"
> >
<gl-button <gl-button
v-if="showDropdownContentsCreateView" v-if="showDropdownContentsCreateView"
...@@ -119,27 +140,31 @@ export default { ...@@ -119,27 +140,31 @@ export default {
size="small" size="small"
class="dropdown-header-button gl-p-0!" class="dropdown-header-button gl-p-0!"
icon="close" icon="close"
@click="$emit('closeDropdown')" data-testid="close-button"
@click="closeDropdown"
/> />
</div> </div>
</template> </template>
<component <template #default>
:is="dropdownContentsView" <component
:selected-labels="selectedLabels" :is="dropdownContentsView"
:allow-multiselect="allowMultiselect" v-model="localSelectedLabels"
@hideCreateView="toggleDropdownContentsCreateView" :selected-labels="selectedLabels"
@setLabels="$emit('setLabels', $event)" :allow-multiselect="allowMultiselect"
/> :issuable-type="issuableType"
@hideCreateView="toggleDropdownContentsCreateView"
/>
</template>
<template #footer> <template #footer>
<div v-if="showDropdownFooter" data-testid="dropdown-footer"> <div v-if="showDropdownFooter" data-testid="dropdown-footer">
<gl-dropdown-item <gl-dropdown-item
v-if="allowLabelCreate" v-if="allowLabelCreate"
data-testid="create-label-button" data-testid="create-label-button"
@click.native.capture.stop="toggleDropdownContent" @click.capture.native.stop="toggleDropdownContent"
> >
{{ footerCreateLabelTitle }} {{ footerCreateLabelTitle }}
</gl-dropdown-item> </gl-dropdown-item>
<gl-dropdown-item :href="labelsManagePath" @click.native.capture.stop> <gl-dropdown-item :href="labelsManagePath" @click.capture.native.stop>
{{ footerManageLabelTitle }} {{ footerManageLabelTitle }}
</gl-dropdown-item> </gl-dropdown-item>
</div> </div>
......
...@@ -2,9 +2,10 @@ ...@@ -2,9 +2,10 @@
import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui'; import { GlTooltipDirective, GlButton, GlFormInput, GlLink, GlLoadingIcon } from '@gitlab/ui';
import produce from 'immer'; import produce from 'immer';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import { labelsQueries } from '~/sidebar/constants';
import createLabelMutation from './graphql/create_label.mutation.graphql'; import createLabelMutation from './graphql/create_label.mutation.graphql';
import projectLabelsQuery from './graphql/project_labels.query.graphql';
const errorMessage = __('Error creating label.'); const errorMessage = __('Error creating label.');
...@@ -19,10 +20,16 @@ export default { ...@@ -19,10 +20,16 @@ export default {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
}, },
inject: { inject: {
projectPath: { fullPath: {
default: '', default: '',
}, },
}, },
props: {
issuableType: {
type: String,
required: true,
},
},
data() { data() {
return { return {
labelTitle: '', labelTitle: '',
...@@ -38,6 +45,19 @@ export default { ...@@ -38,6 +45,19 @@ export default {
const colorsMap = gon.suggested_label_colors; const colorsMap = gon.suggested_label_colors;
return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] })); return Object.keys(colorsMap).map((color) => ({ [color]: colorsMap[color] }));
}, },
mutationVariables() {
return this.issuableType === IssuableType.Epic
? {
title: this.labelTitle,
color: this.selectedColor,
groupPath: this.fullPath,
}
: {
title: this.labelTitle,
color: this.selectedColor,
projectPath: this.fullPath,
};
},
}, },
methods: { methods: {
getColorCode(color) { getColorCode(color) {
...@@ -51,8 +71,8 @@ export default { ...@@ -51,8 +71,8 @@ export default {
}, },
updateLabelsInCache(store, label) { updateLabelsInCache(store, label) {
const sourceData = store.readQuery({ const sourceData = store.readQuery({
query: projectLabelsQuery, query: labelsQueries[this.issuableType].workspaceQuery,
variables: { fullPath: this.projectPath, searchTerm: '' }, variables: { fullPath: this.fullPath, searchTerm: '' },
}); });
const collator = new Intl.Collator('en'); const collator = new Intl.Collator('en');
...@@ -63,8 +83,8 @@ export default { ...@@ -63,8 +83,8 @@ export default {
}); });
store.writeQuery({ store.writeQuery({
query: projectLabelsQuery, query: labelsQueries[this.issuableType].workspaceQuery,
variables: { fullPath: this.projectPath, searchTerm: '' }, variables: { fullPath: this.fullPath, searchTerm: '' },
data, data,
}); });
}, },
...@@ -75,11 +95,7 @@ export default { ...@@ -75,11 +95,7 @@ export default {
data: { labelCreate }, data: { labelCreate },
} = await this.$apollo.mutate({ } = await this.$apollo.mutate({
mutation: createLabelMutation, mutation: createLabelMutation,
variables: { variables: this.mutationVariables,
title: this.labelTitle,
color: this.selectedColor,
projectPath: this.projectPath,
},
update: ( update: (
store, store,
{ {
......
<script> <script>
import { GlDropdownForm, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import {
GlDropdownForm,
GlDropdownItem,
GlLoadingIcon,
GlSearchBoxByType,
GlIntersectionObserver,
} from '@gitlab/ui';
import fuzzaldrinPlus from 'fuzzaldrin-plus'; import fuzzaldrinPlus from 'fuzzaldrin-plus';
import { debounce } from 'lodash'; import { debounce } from 'lodash';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { getIdFromGraphQLId } from '~/graphql_shared/utils'; import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { __ } from '~/locale'; import { __ } from '~/locale';
import projectLabelsQuery from './graphql/project_labels.query.graphql'; import { labelsQueries } from '~/sidebar/constants';
import LabelItem from './label_item.vue'; import LabelItem from './label_item.vue';
export default { export default {
...@@ -15,9 +21,13 @@ export default { ...@@ -15,9 +21,13 @@ export default {
GlDropdownItem, GlDropdownItem,
GlLoadingIcon, GlLoadingIcon,
GlSearchBoxByType, GlSearchBoxByType,
GlIntersectionObserver,
LabelItem, LabelItem,
}, },
inject: ['projectPath'], inject: ['fullPath'],
model: {
prop: 'localSelectedLabels',
},
props: { props: {
selectedLabels: { selectedLabels: {
type: Array, type: Array,
...@@ -27,20 +37,29 @@ export default { ...@@ -27,20 +37,29 @@ export default {
type: Boolean, type: Boolean,
required: true, required: true,
}, },
issuableType: {
type: String,
required: true,
},
localSelectedLabels: {
type: Array,
required: true,
},
}, },
data() { data() {
return { return {
searchKey: '', searchKey: '',
labels: [], labels: [],
localSelectedLabels: [...this.selectedLabels],
}; };
}, },
apollo: { apollo: {
labels: { labels: {
query: projectLabelsQuery, query() {
return labelsQueries[this.issuableType].workspaceQuery;
},
variables() { variables() {
return { return {
fullPath: this.projectPath, fullPath: this.fullPath,
searchTerm: this.searchKey, searchTerm: this.searchKey,
}; };
}, },
...@@ -50,8 +69,8 @@ export default { ...@@ -50,8 +69,8 @@ export default {
update: (data) => data.workspace?.labels?.nodes || [], update: (data) => data.workspace?.labels?.nodes || [],
async result() { async result() {
if (this.$refs.searchInput) { if (this.$refs.searchInput) {
await this.$nextTick(); await this.$nextTick;
this.$refs.searchInput.focusInput(); this.focusInputField();
} }
}, },
error() { error() {
...@@ -82,7 +101,6 @@ export default { ...@@ -82,7 +101,6 @@ export default {
this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS); this.debouncedSearchKeyUpdate = debounce(this.setSearchKey, DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
}, },
beforeDestroy() { beforeDestroy() {
this.$emit('setLabels', this.localSelectedLabels);
this.debouncedSearchKeyUpdate.cancel(); this.debouncedSearchKeyUpdate.cancel();
}, },
methods: { methods: {
...@@ -109,16 +127,19 @@ export default { ...@@ -109,16 +127,19 @@ export default {
} }
}, },
updateSelectedLabels(label) { updateSelectedLabels(label) {
let labels;
if (this.isLabelSelected(label)) { if (this.isLabelSelected(label)) {
this.localSelectedLabels = this.localSelectedLabels.filter( labels = this.localSelectedLabels.filter(({ id }) => id !== getIdFromGraphQLId(label.id));
({ id }) => id !== getIdFromGraphQLId(label.id),
);
} else { } else {
this.localSelectedLabels.push({ labels = [
...label, ...this.localSelectedLabels,
id: getIdFromGraphQLId(label.id), {
}); ...label,
id: getIdFromGraphQLId(label.id),
},
];
} }
this.$emit('input', labels);
}, },
handleLabelClick(label) { handleLabelClick(label) {
this.updateSelectedLabels(label); this.updateSelectedLabels(label);
...@@ -129,46 +150,51 @@ export default { ...@@ -129,46 +150,51 @@ export default {
setSearchKey(value) { setSearchKey(value) {
this.searchKey = value; this.searchKey = value;
}, },
focusInputField() {
this.$refs.searchInput.focusInput();
},
}, },
}; };
</script> </script>
<template> <template>
<gl-dropdown-form class="labels-select-contents-list js-labels-list"> <gl-intersection-observer @appear="focusInputField">
<gl-search-box-by-type <gl-dropdown-form class="labels-select-contents-list js-labels-list">
ref="searchInput" <gl-search-box-by-type
:value="searchKey" ref="searchInput"
:disabled="labelsFetchInProgress" :value="searchKey"
data-qa-selector="dropdown_input_field" :disabled="labelsFetchInProgress"
data-testid="dropdown-input-field" data-qa-selector="dropdown_input_field"
@input="debouncedSearchKeyUpdate" data-testid="dropdown-input-field"
/> @input="debouncedSearchKeyUpdate"
<div ref="labelsListContainer" data-testid="dropdown-content">
<gl-loading-icon
v-if="labelsFetchInProgress"
class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full"
size="md"
/> />
<template v-else> <div ref="labelsListContainer" data-testid="dropdown-content">
<gl-dropdown-item <gl-loading-icon
v-for="label in visibleLabels" v-if="labelsFetchInProgress"
:key="label.id" class="labels-fetch-loading gl-align-items-center gl-w-full gl-h-full gl-mb-3"
:is-checked="isLabelSelected(label)" size="md"
:is-check-centered="true" />
:is-check-item="true" <template v-else>
data-testid="labels-list" <gl-dropdown-item
@click.native.capture.stop="handleLabelClick(label)" v-for="label in visibleLabels"
> :key="label.id"
<label-item :label="label" /> :is-checked="isLabelSelected(label)"
</gl-dropdown-item> :is-check-centered="true"
<gl-dropdown-item :is-check-item="true"
v-show="showNoMatchingResultsMessage" data-testid="labels-list"
class="gl-p-3 gl-text-center" @click.native.capture.stop="handleLabelClick(label)"
data-testid="no-results" >
> <label-item :label="label" />
{{ __('No matching results') }} </gl-dropdown-item>
</gl-dropdown-item> <gl-dropdown-item
</template> v-show="showNoMatchingResultsMessage"
</div> class="gl-p-3 gl-text-center"
</gl-dropdown-form> data-testid="no-results"
>
{{ __('No matching results') }}
</gl-dropdown-item>
</template>
</div>
</gl-dropdown-form>
</gl-intersection-observer>
</template> </template>
query epicLabels($fullPath: ID!, $iid: ID) {
workspace: group(fullPath: $fullPath) {
issuable: epic(iid: $iid) {
id
labels {
nodes {
id
title
color
description
}
}
}
}
}
query groupLabels($fullPath: ID!, $searchTerm: String) {
workspace: group(fullPath: $fullPath) {
labels(searchTerm: $searchTerm, onlyGroupLabels: true) {
nodes {
id
title
color
description
}
}
}
}
<script> <script>
import Vue from 'vue'; import createFlash from '~/flash';
import Vuex from 'vuex';
import { __ } from '~/locale'; import { __ } from '~/locale';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import { labelsQueries } from '~/sidebar/constants';
import { DropdownVariant } from './constants'; import { DropdownVariant } from './constants';
import DropdownContents from './dropdown_contents.vue'; import DropdownContents from './dropdown_contents.vue';
import DropdownValue from './dropdown_value.vue'; import DropdownValue from './dropdown_value.vue';
import DropdownValueCollapsed from './dropdown_value_collapsed.vue'; import DropdownValueCollapsed from './dropdown_value_collapsed.vue';
import issueLabelsQuery from './graphql/issue_labels.query.graphql';
import { import {
isDropdownVariantSidebar, isDropdownVariantSidebar,
isDropdownVariantStandalone, isDropdownVariantStandalone,
isDropdownVariantEmbedded, isDropdownVariantEmbedded,
} from './utils'; } from './utils';
Vue.use(Vuex);
export default { export default {
components: { components: {
DropdownValue, DropdownValue,
...@@ -23,7 +20,15 @@ export default { ...@@ -23,7 +20,15 @@ export default {
DropdownValueCollapsed, DropdownValueCollapsed,
SidebarEditableItem, SidebarEditableItem,
}, },
inject: ['iid', 'projectPath', 'allowLabelEdit'], inject: {
iid: {
default: '',
},
allowLabelEdit: {
default: false,
},
fullPath: {},
},
props: { props: {
allowLabelRemove: { allowLabelRemove: {
type: Boolean, type: Boolean,
...@@ -90,43 +95,52 @@ export default { ...@@ -90,43 +95,52 @@ export default {
required: false, required: false,
default: false, default: false,
}, },
issuableType: {
type: String,
required: true,
},
}, },
data() { data() {
return { return {
contentIsOnViewport: true, contentIsOnViewport: true,
issueLabels: [], issuableLabels: [],
}; };
}, },
computed: {
isLoading() {
return this.labelsSelectInProgress || this.$apollo.queries.issuableLabels.loading;
},
},
apollo: { apollo: {
issueLabels: { issuableLabels: {
query: issueLabelsQuery, query() {
return labelsQueries[this.issuableType].issuableQuery;
},
skip() {
return !isDropdownVariantSidebar(this.variant);
},
variables() { variables() {
return { return {
iid: this.iid, iid: this.iid,
fullPath: this.projectPath, fullPath: this.fullPath,
}; };
}, },
update(data) { update(data) {
return data.workspace?.issuable?.labels.nodes || []; return data.workspace?.issuable?.labels.nodes || [];
}, },
error() {
createFlash({ message: __('Error fetching labels.') });
},
}, },
}, },
methods: { methods: {
handleDropdownClose(labels) { handleDropdownClose(labels) {
if (labels.length) this.$emit('updateSelectedLabels', labels); this.$emit('updateSelectedLabels', labels);
this.$emit('onDropdownClose'); this.$refs.editable?.collapse();
},
collapseDropdown() {
this.$refs.editable.collapse();
}, },
handleCollapsedValueClick() { handleCollapsedValueClick() {
this.$emit('toggleCollapse'); this.$emit('toggleCollapse');
}, },
showDropdown() {
this.$nextTick(() => {
this.$refs.dropdownContents.showDropdown();
});
},
isDropdownVariantSidebar, isDropdownVariantSidebar,
isDropdownVariantStandalone, isDropdownVariantStandalone,
isDropdownVariantEmbedded, isDropdownVariantEmbedded,
...@@ -145,20 +159,19 @@ export default { ...@@ -145,20 +159,19 @@ export default {
<template v-if="isDropdownVariantSidebar(variant)"> <template v-if="isDropdownVariantSidebar(variant)">
<dropdown-value-collapsed <dropdown-value-collapsed
ref="dropdownButtonCollapsed" ref="dropdownButtonCollapsed"
:labels="issueLabels" :labels="issuableLabels"
@onValueClick="handleCollapsedValueClick" @onValueClick="handleCollapsedValueClick"
/> />
<sidebar-editable-item <sidebar-editable-item
ref="editable" ref="editable"
:title="__('Labels')" :title="__('Labels')"
:loading="labelsSelectInProgress" :loading="isLoading"
:can-edit="allowLabelEdit" :can-edit="allowLabelEdit"
@open="showDropdown"
> >
<template #collapsed> <template #collapsed>
<dropdown-value <dropdown-value
:disable-labels="labelsSelectInProgress" :disable-labels="labelsSelectInProgress"
:selected-labels="issueLabels" :selected-labels="issuableLabels"
:allow-label-remove="allowLabelRemove" :allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath" :labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam" :labels-filter-param="labelsFilterParam"
...@@ -170,7 +183,7 @@ export default { ...@@ -170,7 +183,7 @@ export default {
<template #default="{ edit }"> <template #default="{ edit }">
<dropdown-value <dropdown-value
:disable-labels="labelsSelectInProgress" :disable-labels="labelsSelectInProgress"
:selected-labels="issueLabels" :selected-labels="issuableLabels"
:allow-label-remove="allowLabelRemove" :allow-label-remove="allowLabelRemove"
:labels-filter-base-path="labelsFilterBasePath" :labels-filter-base-path="labelsFilterBasePath"
:labels-filter-param="labelsFilterParam" :labels-filter-param="labelsFilterParam"
...@@ -181,7 +194,6 @@ export default { ...@@ -181,7 +194,6 @@ export default {
</dropdown-value> </dropdown-value>
<dropdown-contents <dropdown-contents
v-if="edit" v-if="edit"
ref="dropdownContents"
:dropdown-button-text="dropdownButtonText" :dropdown-button-text="dropdownButtonText"
:allow-multiselect="allowMultiselect" :allow-multiselect="allowMultiselect"
:labels-list-title="labelsListTitle" :labels-list-title="labelsListTitle"
...@@ -190,11 +202,25 @@ export default { ...@@ -190,11 +202,25 @@ export default {
:labels-create-title="labelsCreateTitle" :labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels" :selected-labels="selectedLabels"
:variant="variant" :variant="variant"
@closeDropdown="collapseDropdown" :issuable-type="issuableType"
@setLabels="handleDropdownClose" @setLabels="handleDropdownClose"
/> />
</template> </template>
</sidebar-editable-item> </sidebar-editable-item>
</template> </template>
<dropdown-contents
v-else
ref="dropdownContents"
:allow-multiselect="allowMultiselect"
:dropdown-button-text="dropdownButtonText"
:labels-list-title="labelsListTitle"
:footer-create-label-title="footerCreateLabelTitle"
:footer-manage-label-title="footerManageLabelTitle"
:labels-create-title="labelsCreateTitle"
:selected-labels="selectedLabels"
:variant="variant"
:issuable-type="issuableType"
@setLabels="handleDropdownClose"
/>
</div> </div>
</template> </template>
...@@ -13,6 +13,8 @@ import { visitUrl } from '~/lib/utils/url_utility'; ...@@ -13,6 +13,8 @@ import { visitUrl } from '~/lib/utils/url_utility';
import { s__ } from '~/locale'; import { s__ } from '~/locale';
import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import LabelsSelectVue from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; import LabelsSelectVue 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';
import createEpic from '../queries/createEpic.mutation.graphql'; import createEpic from '../queries/createEpic.mutation.graphql';
export default { export default {
...@@ -25,7 +27,9 @@ export default { ...@@ -25,7 +27,9 @@ export default {
GlFormGroup, GlFormGroup,
MarkdownField, MarkdownField,
LabelsSelectVue, LabelsSelectVue,
LabelsSelectWidget,
}, },
mixins: [glFeatureFlagMixin()],
inject: [ inject: [
'groupPath', 'groupPath',
'groupEpicsPath', 'groupEpicsPath',
...@@ -106,6 +110,11 @@ export default { ...@@ -106,6 +110,11 @@ export default {
this.startDateFixed = val; this.startDateFixed = val;
}, },
handleUpdateSelectedLabels(labels) { handleUpdateSelectedLabels(labels) {
if (this.glFeatures.labelsWidget) {
this.labels = labels;
return;
}
const ids = []; const ids = [];
const allLabels = [...labels, ...this.labels]; const allLabels = [...labels, ...this.labels];
...@@ -177,7 +186,23 @@ export default { ...@@ -177,7 +186,23 @@ export default {
</gl-form-group> </gl-form-group>
<hr /> <hr />
<gl-form-group :label="__('Labels')"> <gl-form-group :label="__('Labels')">
<labels-select-widget
v-if="glFeatures.labelsWidget"
class="block labels js-labels-block"
:allow-label-create="true"
:allow-multiselect="true"
:allow-scoped-labels="false"
:labels-filter-base-path="groupEpicsPath"
:selected-labels="labels"
issuable-type="epic"
variant="embedded"
data-qa-selector="labels_block"
@updateSelectedLabels="handleUpdateSelectedLabels"
>
{{ __('None') }}
</labels-select-widget>
<labels-select-vue <labels-select-vue
v-else
:allow-label-edit="false" :allow-label-edit="false"
:allow-label-create="true" :allow-label-create="true"
:allow-multiselect="true" :allow-multiselect="true"
......
...@@ -35,6 +35,8 @@ export function initEpicForm() { ...@@ -35,6 +35,8 @@ export function initEpicForm() {
apolloProvider, apolloProvider,
provide: { provide: {
groupPath, groupPath,
fullPath: groupPath,
allowLabelCreate: true,
groupEpicsPath, groupEpicsPath,
labelsFetchPath, labelsFetchPath,
labelsManagePath, labelsManagePath,
......
...@@ -23,6 +23,7 @@ class Groups::EpicsController < Groups::ApplicationController ...@@ -23,6 +23,7 @@ class Groups::EpicsController < Groups::ApplicationController
before_action do before_action do
push_frontend_feature_flag(:vue_epics_list, @group, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:vue_epics_list, @group, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:improved_emoji_picker, @group, type: :development, default_enabled: :yaml) push_frontend_feature_flag(:improved_emoji_picker, @group, type: :development, default_enabled: :yaml)
push_frontend_feature_flag(:labels_widget, @group, default_enabled: :yaml)
end end
feature_category :epics feature_category :epics
......
...@@ -110,10 +110,9 @@ describe('sidebar labels', () => { ...@@ -110,10 +110,9 @@ describe('sidebar labels', () => {
mutation: updateIssueLabelsMutation, mutation: updateIssueLabelsMutation,
variables: { variables: {
input: { input: {
addLabelIds: [40],
iid: defaultProps.iid, iid: defaultProps.iid,
projectPath: defaultProps.projectPath, projectPath: defaultProps.projectPath,
removeLabelIds: [26, 55], labelIds: [toLabelGid(29), toLabelGid(28), toLabelGid(27), toLabelGid(40)],
}, },
}, },
}; };
......
...@@ -5,13 +5,14 @@ import VueApollo from 'vue-apollo'; ...@@ -5,13 +5,14 @@ import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { labelsQueries } from '~/sidebar/constants';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql';
import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import { import {
mockSuggestedColors, mockSuggestedColors,
createLabelSuccessfulResponse, createLabelSuccessfulResponse,
labelsQueryResponse, workspaceLabelsQueryResponse,
} from './mock_data'; } from './mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
...@@ -47,11 +48,14 @@ describe('DropdownContentsCreateView', () => { ...@@ -47,11 +48,14 @@ describe('DropdownContentsCreateView', () => {
findAllColors().at(0).vm.$emit('click', new Event('mouseclick')); findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
}; };
const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => { const createComponent = ({
mutationHandler = createLabelSuccessHandler,
issuableType = IssuableType.Issue,
} = {}) => {
const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]); const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
mockApollo.clients.defaultClient.cache.writeQuery({ mockApollo.clients.defaultClient.cache.writeQuery({
query: projectLabelsQuery, query: labelsQueries[issuableType].workspaceQuery,
data: labelsQueryResponse.data, data: workspaceLabelsQueryResponse.data,
variables: { variables: {
fullPath: '', fullPath: '',
searchTerm: '', searchTerm: '',
...@@ -61,6 +65,9 @@ describe('DropdownContentsCreateView', () => { ...@@ -61,6 +65,9 @@ describe('DropdownContentsCreateView', () => {
wrapper = shallowMount(DropdownContentsCreateView, { wrapper = shallowMount(DropdownContentsCreateView, {
localVue, localVue,
apolloProvider: mockApollo, apolloProvider: mockApollo,
propsData: {
issuableType,
},
}); });
}; };
...@@ -135,15 +142,6 @@ describe('DropdownContentsCreateView', () => { ...@@ -135,15 +142,6 @@ describe('DropdownContentsCreateView', () => {
expect(findCreateButton().props('disabled')).toBe(false); expect(findCreateButton().props('disabled')).toBe(false);
}); });
it('calls a mutation with correct parameters on Create button click', () => {
findCreateButton().vm.$emit('click');
expect(createLabelSuccessHandler).toHaveBeenCalledWith({
color: '#009966',
projectPath: '',
title: 'Test title',
});
});
it('renders a loader spinner after Create button click', async () => { it('renders a loader spinner after Create button click', async () => {
findCreateButton().vm.$emit('click'); findCreateButton().vm.$emit('click');
await nextTick(); await nextTick();
...@@ -162,6 +160,30 @@ describe('DropdownContentsCreateView', () => { ...@@ -162,6 +160,30 @@ describe('DropdownContentsCreateView', () => {
}); });
}); });
it('calls a mutation with `projectPath` variable on the issue', () => {
createComponent();
fillLabelAttributes();
findCreateButton().vm.$emit('click');
expect(createLabelSuccessHandler).toHaveBeenCalledWith({
color: '#009966',
projectPath: '',
title: 'Test title',
});
});
it('calls a mutation with `groupPath` variable on the epic', () => {
createComponent({ issuableType: IssuableType.Epic });
fillLabelAttributes();
findCreateButton().vm.$emit('click');
expect(createLabelSuccessHandler).toHaveBeenCalledWith({
color: '#009966',
groupPath: '',
title: 'Test title',
});
});
it('calls createFlash is mutation has a user-recoverable error', async () => { it('calls createFlash is mutation has a user-recoverable error', async () => {
createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler }); createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler });
fillLabelAttributes(); fillLabelAttributes();
......
import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui';
import { shallowMount, createLocalVue } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue'; import { nextTick } from 'vue';
import VueApollo from 'vue-apollo'; import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper'; import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises'; import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash'; import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql';
import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
import { mockConfig, labelsQueryResponse } from './mock_data'; import { mockConfig, workspaceLabelsQueryResponse } from './mock_data';
jest.mock('~/flash'); jest.mock('~/flash');
const localVue = createLocalVue(); const localVue = createLocalVue();
localVue.use(VueApollo); localVue.use(VueApollo);
const selectedLabels = [ const localSelectedLabels = [
{ {
id: 28, color: '#2f7b2e',
title: 'Bug', description: null,
description: 'Label for bugs', id: 'gid://gitlab/ProjectLabel/2',
color: '#FF0000', title: 'Label2',
textColor: '#FFFFFF',
}, },
]; ];
describe('DropdownContentsLabelsView', () => { describe('DropdownContentsLabelsView', () => {
let wrapper; let wrapper;
const successfulQueryHandler = jest.fn().mockResolvedValue(labelsQueryResponse); const successfulQueryHandler = jest.fn().mockResolvedValue(workspaceLabelsQueryResponse);
const findFirstLabel = () => wrapper.findAllComponents(GlDropdownItem).at(0);
const createComponent = ({ const createComponent = ({
initialState = mockConfig, initialState = mockConfig,
...@@ -43,14 +45,15 @@ describe('DropdownContentsLabelsView', () => { ...@@ -43,14 +45,15 @@ describe('DropdownContentsLabelsView', () => {
localVue, localVue,
apolloProvider: mockApollo, apolloProvider: mockApollo,
provide: { provide: {
projectPath: 'test', fullPath: 'test',
iid: 1, iid: 1,
variant: DropdownVariant.Sidebar, variant: DropdownVariant.Sidebar,
...injected, ...injected,
}, },
propsData: { propsData: {
...initialState, ...initialState,
selectedLabels, localSelectedLabels,
issuableType: IssuableType.Issue,
}, },
stubs: { stubs: {
GlSearchBoxByType, GlSearchBoxByType,
...@@ -129,6 +132,15 @@ describe('DropdownContentsLabelsView', () => { ...@@ -129,6 +132,15 @@ describe('DropdownContentsLabelsView', () => {
createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') }); createComponent({ queryHandler: jest.fn().mockRejectedValue('Houston, we have a problem!') });
jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS);
await waitForPromises(); await waitForPromises();
expect(createFlash).toHaveBeenCalled(); expect(createFlash).toHaveBeenCalled();
}); });
it('emits an `input` event on label click', async () => {
createComponent();
await waitForPromises();
findFirstLabel().trigger('click');
expect(wrapper.emitted('input')[0][0]).toEqual(expect.arrayContaining(localSelectedLabels));
});
}); });
import { GlDropdown } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { nextTick } from 'vue';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue';
...@@ -8,10 +7,25 @@ import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_s ...@@ -8,10 +7,25 @@ import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_s
import { mockLabels } from './mock_data'; import { mockLabels } from './mock_data';
const showDropdown = jest.fn();
const GlDropdownStub = {
template: `
<div data-testid="dropdown">
<slot name="header"></slot>
<slot></slot>
<slot name="footer"></slot>
</div>
`,
methods: {
show: showDropdown,
},
};
describe('DropdownContent', () => { describe('DropdownContent', () => {
let wrapper; let wrapper;
const createComponent = ({ props = {}, injected = {} } = {}) => { const createComponent = ({ props = {}, injected = {}, data = {} } = {}) => {
wrapper = shallowMount(DropdownContents, { wrapper = shallowMount(DropdownContents, {
propsData: { propsData: {
labelsCreateTitle: 'test', labelsCreateTitle: 'test',
...@@ -22,38 +36,76 @@ describe('DropdownContent', () => { ...@@ -22,38 +36,76 @@ describe('DropdownContent', () => {
footerManageLabelTitle: 'manage', footerManageLabelTitle: 'manage',
dropdownButtonText: 'Labels', dropdownButtonText: 'Labels',
variant: 'sidebar', variant: 'sidebar',
issuableType: 'issue',
...props, ...props,
}, },
data() {
return {
...data,
};
},
provide: { provide: {
allowLabelCreate: true, allowLabelCreate: true,
labelsManagePath: 'foo/bar', labelsManagePath: 'foo/bar',
...injected, ...injected,
}, },
stubs: { stubs: {
GlDropdown, GlDropdown: GlDropdownStub,
}, },
}); });
}; };
beforeEach(() => {
createComponent();
});
afterEach(() => { afterEach(() => {
wrapper.destroy(); wrapper.destroy();
}); });
const findCreateView = () => wrapper.findComponent(DropdownContentsCreateView);
const findLabelsView = () => wrapper.findComponent(DropdownContentsLabelsView);
const findDropdown = () => wrapper.findComponent(GlDropdownStub);
const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
const findDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]');
const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]'); const findCreateLabelButton = () => wrapper.find('[data-testid="create-label-button"]');
const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]'); const findGoBackButton = () => wrapper.find('[data-testid="go-back-button"]');
it('calls dropdown `show` method on component mount', () => {
createComponent();
expect(showDropdown).toHaveBeenCalled();
});
it('emits `setLabels` event on dropdown hide', () => {
createComponent();
findDropdown().vm.$emit('hide');
expect(wrapper.emitted('setLabels')).toEqual([[mockLabels]]);
});
it('does not render header on standalone variant', () => {
createComponent({ props: { variant: DropdownVariant.Standalone } });
expect(findDropdownHeader().exists()).toBe(false);
});
it('renders header on embedded variant', () => {
createComponent({ props: { variant: DropdownVariant.Embedded } });
expect(findDropdownHeader().exists()).toBe(true);
});
it('renders header on sidebar variant', () => {
createComponent();
expect(findDropdownHeader().exists()).toBe(true);
});
describe('Create view', () => { describe('Create view', () => {
beforeEach(() => { beforeEach(() => {
wrapper.vm.toggleDropdownContentsCreateView(); createComponent({ data: { showDropdownContentsCreateView: true } });
}); });
it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => { it('renders create view when `showDropdownContentsCreateView` prop is `true`', () => {
expect(wrapper.findComponent(DropdownContentsCreateView).exists()).toBe(true); expect(findCreateView().exists()).toBe(true);
}); });
it('does not render footer', () => { it('does not render footer', () => {
...@@ -67,11 +119,31 @@ describe('DropdownContent', () => { ...@@ -67,11 +119,31 @@ describe('DropdownContent', () => {
it('renders go back button', () => { it('renders go back button', () => {
expect(findGoBackButton().exists()).toBe(true); expect(findGoBackButton().exists()).toBe(true);
}); });
it('changes the view to Labels view on back button click', async () => {
findGoBackButton().vm.$emit('click', new MouseEvent('click'));
await nextTick();
expect(findCreateView().exists()).toBe(false);
expect(findLabelsView().exists()).toBe(true);
});
it('changes the view to Labels view on `hideCreateView` event', async () => {
findCreateView().vm.$emit('hideCreateView');
await nextTick();
expect(findCreateView().exists()).toBe(false);
expect(findLabelsView().exists()).toBe(true);
});
}); });
describe('Labels view', () => { describe('Labels view', () => {
beforeEach(() => {
createComponent();
});
it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => { it('renders labels view when `showDropdownContentsCreateView` when `showDropdownContentsCreateView` prop is `false`', () => {
expect(wrapper.findComponent(DropdownContentsLabelsView).exists()).toBe(true); expect(findLabelsView().exists()).toBe(true);
}); });
it('renders footer on sidebar dropdown', () => { it('renders footer on sidebar dropdown', () => {
...@@ -109,19 +181,12 @@ describe('DropdownContent', () => { ...@@ -109,19 +181,12 @@ describe('DropdownContent', () => {
expect(findCreateLabelButton().exists()).toBe(true); expect(findCreateLabelButton().exists()).toBe(true);
}); });
it('triggers `toggleDropdownContent` method on create label button click', () => { it('changes the view to Create on create label button click', async () => {
jest.spyOn(wrapper.vm, 'toggleDropdownContent').mockImplementation(() => {});
findCreateLabelButton().trigger('click'); findCreateLabelButton().trigger('click');
expect(wrapper.vm.toggleDropdownContent).toHaveBeenCalled(); await nextTick();
expect(findLabelsView().exists()).toBe(false);
}); });
}); });
}); });
describe('template', () => {
it('renders component container element with classes `gl-w-full gl-mt-2` and no styles', () => {
expect(wrapper.attributes('class')).toContain('gl-w-full gl-mt-2');
expect(wrapper.attributes('style')).toBeUndefined();
});
});
}); });
import { shallowMount } from '@vue/test-utils'; import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
import createMockApollo from 'helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import { IssuableType } from '~/issue_show/constants';
import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value_collapsed.vue'; import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql';
import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { mockConfig, issuableLabelsQueryResponse } from './mock_data';
import { mockConfig } from './mock_data'; jest.mock('~/flash');
const localVue = createLocalVue();
localVue.use(VueApollo);
const successfulQueryHandler = jest.fn().mockResolvedValue(issuableLabelsQueryResponse);
const errorQueryHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
describe('LabelsSelectRoot', () => { describe('LabelsSelectRoot', () => {
let wrapper; let wrapper;
const createComponent = (config = mockConfig, slots = {}) => { const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem);
const findDropdownValue = () => wrapper.findComponent(DropdownValue);
const findDropdownContents = () => wrapper.findComponent(DropdownContents);
const expandDropdown = () => wrapper.vm.$refs.editable.expand();
const createComponent = ({
config = mockConfig,
slots = {},
queryHandler = successfulQueryHandler,
} = {}) => {
const mockApollo = createMockApollo([[issueLabelsQuery, queryHandler]]);
wrapper = shallowMount(LabelsSelectRoot, { wrapper = shallowMount(LabelsSelectRoot, {
slots, slots,
propsData: config, apolloProvider: mockApollo,
localVue,
propsData: {
...config,
issuableType: IssuableType.Issue,
},
stubs: { stubs: {
DropdownContents,
SidebarEditableItem, SidebarEditableItem,
}, },
provide: { provide: {
iid: '1', iid: '1',
projectPath: 'test', fullPath: 'test',
canUpdate: true, canUpdate: true,
allowLabelEdit: true, allowLabelEdit: true,
allowLabelCreate: true,
labelsManagePath: 'test',
}, },
}); });
}; };
...@@ -42,33 +73,67 @@ describe('LabelsSelectRoot', () => { ...@@ -42,33 +73,67 @@ describe('LabelsSelectRoot', () => {
${'embedded'} | ${'is-embedded'} ${'embedded'} | ${'is-embedded'}
`( `(
'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"', 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
({ variant, cssClass }) => { async ({ variant, cssClass }) => {
createComponent({ createComponent({
...mockConfig, config: { ...mockConfig, variant },
variant,
}); });
return wrapper.vm.$nextTick(() => { await nextTick();
expect(wrapper.classes()).toContain(cssClass); expect(wrapper.classes()).toContain(cssClass);
});
}, },
); );
it('renders `dropdown-value-collapsed` component when `allowLabelCreate` prop is `true`', async () => { describe('if dropdown variant is `sidebar`', () => {
createComponent(); it('renders sidebar editable item', () => {
await wrapper.vm.$nextTick; createComponent();
expect(wrapper.find(DropdownValueCollapsed).exists()).toBe(true); expect(findSidebarEditableItem().exists()).toBe(true);
}); });
it('passes true `loading` prop to sidebar editable item when loading labels', () => {
createComponent();
expect(findSidebarEditableItem().props('loading')).toBe(true);
});
it('renders `dropdown-value` component', async () => { describe('when labels are fetched successfully', () => {
createComponent(mockConfig, { beforeEach(async () => {
default: 'None', createComponent();
await waitForPromises();
});
it('passes true `loading` prop to sidebar editable item', () => {
expect(findSidebarEditableItem().props('loading')).toBe(false);
});
it('renders dropdown value component when query labels is resolved', () => {
expect(findDropdownValue().exists()).toBe(true);
expect(findDropdownValue().props('selectedLabels')).toEqual(
issuableLabelsQueryResponse.data.workspace.issuable.labels.nodes,
);
});
it('emits `onLabelRemove` event on dropdown value label remove event', () => {
const label = { id: 'gid://gitlab/ProjectLabel/1' };
findDropdownValue().vm.$emit('onLabelRemove', label);
expect(wrapper.emitted('onLabelRemove')).toEqual([[label]]);
});
});
it('creates flash with error message when query is rejected', async () => {
createComponent({ queryHandler: errorQueryHandler });
await waitForPromises();
expect(createFlash).toHaveBeenCalledWith({ message: 'Error fetching labels.' });
}); });
await wrapper.vm.$nextTick; });
it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if there are labels to update', async () => {
const label = { id: 'gid://gitlab/ProjectLabel/1' };
createComponent();
await waitForPromises();
const valueComp = wrapper.find(DropdownValue); expandDropdown();
await nextTick();
expect(valueComp.exists()).toBe(true); findDropdownContents().vm.$emit('setLabels', [label]);
expect(valueComp.text()).toBe('None'); expect(wrapper.emitted('updateSelectedLabels')).toEqual([[[label]]]);
}); });
}); });
...@@ -86,7 +86,7 @@ export const createLabelSuccessfulResponse = { ...@@ -86,7 +86,7 @@ export const createLabelSuccessfulResponse = {
}, },
}; };
export const labelsQueryResponse = { export const workspaceLabelsQueryResponse = {
data: { data: {
workspace: { workspace: {
labels: { labels: {
...@@ -108,3 +108,23 @@ export const labelsQueryResponse = { ...@@ -108,3 +108,23 @@ export const labelsQueryResponse = {
}, },
}, },
}; };
export const issuableLabelsQueryResponse = {
data: {
workspace: {
issuable: {
id: '1',
labels: {
nodes: [
{
color: '#330066',
description: null,
id: 'gid://gitlab/ProjectLabel/1',
title: 'Label1',
},
],
},
},
},
},
};
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