Commit 0e1b481c authored by Natalia Tepluhina's avatar Natalia Tepluhina Committed by Simon Knox

Fixed labels dropdown to work with embedded

Removed Vuex and skip query
Fixed fetching workspace labels
Moved selected labels to dropdown contents
Applies  onlyGroup filter for epics labels
Replaced project path with full path
parent cbb9ab43
......@@ -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