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