Commit c41674b6 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch 'follow-up-labels-widget' into 'master'

Tidy up labels widget [RUN AS-IF-FOSS]

See merge request gitlab-org/gitlab!75805
parents 1deeaa3b a5d23607
<script>
import { GlLabel } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import Api from '~/api';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import { isScopedLabel } from '~/lib/utils/common_utils';
import { mergeUrlParams } from '~/lib/utils/url_utility';
import { __ } from '~/locale';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
export default {
components: {
BoardEditableItem,
LabelsSelect,
GlLabel,
},
inject: {
labelsFetchPath: {
default: null,
},
labelsManagePath: {},
labelsFilterBasePath: {},
},
data() {
return {
loading: false,
oldIid: null,
isEditing: false,
};
},
computed: {
...mapGetters(['activeBoardItem', 'projectPathForActiveIssue']),
selectedLabels() {
const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
id: getIdFromGraphQLId(label.id),
}));
},
issueLabels() {
const { labels = [] } = this.activeBoardItem;
return labels.map((label) => ({
...label,
scoped: isScopedLabel(label),
}));
},
fetchPath() {
/*
Labels fetched in epic boards are always group-level labels
and the correct path are passed from the backend (injected through labelsFetchPath)
For issue boards, we should always include project-level labels and use a different endpoint.
(it requires knowing the project path of a selected issue.)
Note 1. that we will be using GraphQL to fetch labels when we create a labels select widget.
And this component will be removed _wholesale_ https://gitlab.com/gitlab-org/gitlab/-/issues/300653.
Note 2. Moreover, 'fetchPath' needs to be used as a key for 'labels-select' component to force updates.
'labels-select' has its own vuex store and initializes the passed props as states
and these states aren't reactively bound to the passed props.
*/
const projectLabelsFetchPath = mergeUrlParams(
{ include_ancestor_groups: true },
Api.buildUrl(Api.projectLabelsPath).replace(
':namespace_path/:project_path',
this.projectPathForActiveIssue,
),
);
return this.labelsFetchPath || projectLabelsFetchPath;
},
},
watch: {
activeBoardItem(_, oldVal) {
if (this.isEditing) {
this.oldIid = oldVal.iid;
} else {
this.oldIid = null;
}
},
},
methods: {
...mapActions(['setActiveBoardItemLabels', 'setError']),
async setLabels(payload) {
this.loading = true;
this.$refs.sidebarItem.collapse();
try {
const addLabelIds = payload.filter((label) => label.set).map((label) => label.id);
const removeLabelIds = payload.filter((label) => !label.set).map((label) => label.id);
const input = {
addLabelIds,
removeLabelIds,
projectPath: this.projectPathForActiveIssue,
iid: this.oldIid,
};
await this.setActiveBoardItemLabels(input);
this.oldIid = null;
} catch (e) {
this.setError({ error: e, message: __('An error occurred while updating labels.') });
} finally {
this.loading = false;
}
},
async removeLabel(id) {
this.loading = true;
try {
const removeLabelIds = [getIdFromGraphQLId(id)];
const input = { removeLabelIds, projectPath: this.projectPathForActiveIssue };
await this.setActiveBoardItemLabels(input);
} catch (e) {
this.setError({ error: e, message: __('An error occurred when removing the label.') });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<board-editable-item
ref="sidebarItem"
:title="__('Labels')"
:loading="loading"
data-testid="sidebar-labels"
@open="isEditing = true"
@close="isEditing = false"
>
<template #collapsed>
<gl-label
v-for="label in issueLabels"
:key="label.id"
:background-color="label.color"
:title="label.title"
:description="label.description"
:scoped="label.scoped"
:show-close-button="true"
:disabled="loading"
class="gl-mr-2 gl-mb-2"
@close="removeLabel(label.id)"
/>
</template>
<template #default="{ edit }">
<labels-select
ref="labelsSelect"
:key="fetchPath"
:allow-label-edit="false"
:allow-label-create="false"
:allow-multiselect="true"
:allow-scoped-labels="true"
:selected-labels="selectedLabels"
:labels-fetch-path="fetchPath"
:labels-manage-path="labelsManagePath"
:labels-filter-base-path="labelsFilterBasePath"
:labels-list-title="__('Select label')"
:dropdown-button-text="__('Choose labels')"
:is-editing="edit"
variant="sidebar"
class="gl-display-block labels gl-w-full"
@updateSelectedLabels="setLabels"
>
{{ __('None') }}
</labels-select>
</template>
</board-editable-item>
</template>
<script>
import { GlToggle } from '@gitlab/ui';
import { mapGetters, mapActions } from 'vuex';
import { __, s__ } from '~/locale';
export default {
i18n: {
header: {
title: __('Notifications'),
/* Any change to subscribeDisabledDescription
must be reflected in app/helpers/notifications_helper.rb */
subscribeDisabledDescription: __(
'Notifications have been disabled by the project or group owner',
),
},
updateSubscribedErrorMessage: s__(
'IssueBoards|An error occurred while setting notifications status. Please try again.',
),
},
components: {
GlToggle,
},
inject: ['emailsDisabled'],
data() {
return {
loading: false,
};
},
computed: {
...mapGetters(['activeBoardItem', 'projectPathForActiveIssue', 'isEpicBoard']),
isEmailsDisabled() {
return this.isEpicBoard ? this.emailsDisabled : this.activeBoardItem.emailsDisabled;
},
notificationText() {
return this.isEmailsDisabled
? this.$options.i18n.header.subscribeDisabledDescription
: this.$options.i18n.header.title;
},
},
methods: {
...mapActions(['setActiveItemSubscribed', 'setError']),
async handleToggleSubscription() {
this.loading = true;
try {
await this.setActiveItemSubscribed({
subscribed: !this.activeBoardItem.subscribed,
projectPath: this.projectPathForActiveIssue,
});
} catch (error) {
this.setError({ error, message: this.$options.i18n.updateSubscribedErrorMessage });
} finally {
this.loading = false;
}
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between"
data-testid="sidebar-notifications"
>
<span data-testid="notification-header-text"> {{ notificationText }} </span>
<gl-toggle
v-if="!isEmailsDisabled"
:value="activeBoardItem.subscribed"
:is-loading="loading"
:label="$options.i18n.header.title"
label-position="hidden"
data-testid="notification-subscribe-toggle"
@change="handleToggleSubscription"
/>
</div>
</template>
<script>
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
export default {
components: {
LabelsSelectWidget,
},
variant: DropdownVariant.Sidebar,
inject: ['allowLabelEdit', 'iid', 'fullPath', 'issuableType', 'projectIssuesPath'],
data() {
return {
LabelType,
};
},
};
</script>
<template>
<labels-select-widget
class="block labels js-labels-block"
:iid="iid"
:full-path="fullPath"
:allow-label-remove="allowLabelEdit"
:allow-multiselect="true"
:footer-create-label-title="__('Create project label')"
:footer-manage-label-title="__('Manage project labels')"
:labels-create-title="__('Create project label')"
:labels-filter-base-path="projectIssuesPath"
:variant="$options.variant"
:issuable-type="issuableType"
workspace-type="project"
:attr-workspace-path="fullPath"
:label-create-type="LabelType.project"
data-qa-selector="labels_block"
>
{{ __('None') }}
</labels-select-widget>
</template>
...@@ -12,6 +12,7 @@ import { ...@@ -12,6 +12,7 @@ import {
isInIncidentPage, isInIncidentPage,
parseBoolean, parseBoolean,
} from '~/lib/utils/common_utils'; } from '~/lib/utils/common_utils';
import { __ } from '~/locale';
import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue';
import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue';
import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue'; import SidebarConfidentialityWidget from '~/sidebar/components/confidential/sidebar_confidentiality_widget.vue';
...@@ -23,10 +24,11 @@ import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_wid ...@@ -23,10 +24,11 @@ import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_wid
import { apolloProvider } from '~/sidebar/graphql'; import { apolloProvider } from '~/sidebar/graphql';
import trackShowInviteMemberLink from '~/sidebar/track_invite_members'; import trackShowInviteMemberLink from '~/sidebar/track_invite_members';
import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import LabelsSelectWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
import { LabelType } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import Translate from '../vue_shared/translate'; import Translate from '../vue_shared/translate';
import SidebarAssignees from './components/assignees/sidebar_assignees.vue'; import SidebarAssignees from './components/assignees/sidebar_assignees.vue';
import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue'; import CopyEmailToClipboard from './components/copy_email_to_clipboard.vue';
import SidebarLabels from './components/labels/sidebar_labels.vue';
import IssuableLockForm from './components/lock/issuable_lock_form.vue'; import IssuableLockForm from './components/lock/issuable_lock_form.vue';
import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue'; import SidebarReviewers from './components/reviewers/sidebar_reviewers.vue';
import SidebarSeverity from './components/severity/sidebar_severity.vue'; import SidebarSeverity from './components/severity/sidebar_severity.vue';
...@@ -264,7 +266,6 @@ function mountMilestoneSelect() { ...@@ -264,7 +266,6 @@ 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;
...@@ -273,22 +274,43 @@ export function mountSidebarLabels() { ...@@ -273,22 +274,43 @@ export function mountSidebarLabels() {
return new Vue({ return new Vue({
el, el,
apolloProvider, apolloProvider,
components: {
LabelsSelectWidget,
},
provide: { provide: {
...el.dataset, ...el.dataset,
fullPath, canUpdate: parseBoolean(el.dataset.canEdit),
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),
initiallySelectedLabels: JSON.parse(el.dataset.selectedLabels),
variant: DropdownVariant.Sidebar,
canUpdate: parseBoolean(el.dataset.canEdit),
isClassicSidebar: true, isClassicSidebar: true,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
}, },
render: (createElement) => createElement(SidebarLabels), render: (createElement) =>
createElement('labels-select-widget', {
props: {
iid: String(el.dataset.iid),
fullPath: el.dataset.projectPath,
allowLabelRemove: parseBoolean(el.dataset.canEdit),
allowMultiselect: true,
footerCreateLabelTitle: __('Create project label'),
footerManageLabelTitle: __('Manage project labels'),
labelsCreateTitle: __('Create project label'),
labelsFilterBasePath: el.dataset.projectIssuesPath,
variant: DropdownVariant.Sidebar,
issuableType:
isInIssuePage() || isInIncidentPage() || isInDesignPage()
? IssuableType.Issue
: IssuableType.MergeRequest,
workspaceType: 'project',
attrWorkspacePath: el.dataset.projectPath,
labelCreateType: LabelType.project,
},
class: ['block labels js-labels-block'],
scopedSlots: {
default: () => __('None'),
},
}),
}); });
} }
......
...@@ -291,6 +291,7 @@ export default { ...@@ -291,6 +291,7 @@ export default {
'is-standalone': isDropdownVariantStandalone(variant), 'is-standalone': isDropdownVariantStandalone(variant),
'is-embedded': isDropdownVariantEmbedded(variant), 'is-embedded': isDropdownVariantEmbedded(variant),
}" }"
data-qa-selector="labels_block"
> >
<template v-if="isDropdownVariantSidebar(variant)"> <template v-if="isDropdownVariantSidebar(variant)">
<dropdown-value-collapsed <dropdown-value-collapsed
......
...@@ -67,7 +67,6 @@ module NotificationsHelper ...@@ -67,7 +67,6 @@ module NotificationsHelper
when :custom when :custom
_('You will only receive notifications for the events you choose') _('You will only receive notifications for the events you choose')
when :owner_disabled when :owner_disabled
# Any change must be reflected in board_sidebar_subscription.vue
_('Notifications have been disabled by the project or group owner') _('Notifications have been disabled by the project or group owner')
end end
end end
......
<script>
import { debounce } from 'lodash';
import { mapState, mapActions } from 'vuex';
import LabelsSelectVue from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue';
import ListLabel from '../../models/label';
export default {
components: {
LabelsSelectVue,
},
props: {
canUpdate: {
type: Boolean,
required: true,
},
sidebarCollapsed: {
type: Boolean,
required: true,
},
},
data() {
return {
sidebarExpandedOnClick: false,
};
},
computed: {
...mapState([
'epicId',
'labels',
'namespace',
'updateEndpoint',
'labelsPath',
'labelsWebUrl',
'epicsWebUrl',
'scopedLabels',
'epicLabelsSelectInProgress',
]),
epicContext() {
return {
labels: this.labels,
};
},
},
mounted() {
document.addEventListener(
'toggleSidebarRevealLabelsDropdown',
this.toggleSidebarRevealLabelsDropdown,
);
},
beforeDestroy() {
document.removeEventListener(
'toggleSidebarRevealLabelsDropdown',
this.toggleSidebarRevealLabelsDropdown,
);
},
methods: {
...mapActions(['toggleSidebar', 'updateEpicLabels']),
toggleSidebarRevealLabelsDropdown() {
const contentContainer = this.$el.closest('.page-with-contextual-sidebar');
this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed });
// When sidebar is expanded, we need to wait
// for rendering to finish before opening
// dropdown as otherwise it causes `calc()`
// used in CSS to miscalculate collapsed
// sidebar size.
debounce(() => {
this.sidebarExpandedOnClick = true;
if (this.canUpdate && contentContainer) {
contentContainer
.querySelector('.js-sidebar-dropdown-toggle')
.dispatchEvent(new Event('click', { bubbles: true, cancelable: false }));
}
}, 100)();
},
handleDropdownClose() {
if (this.sidebarExpandedOnClick) {
this.sidebarExpandedOnClick = false;
this.toggleSidebar({ sidebarCollapsed: this.sidebarCollapsed });
}
},
handleLabelClick(label) {
if (label.isAny) {
this.epicContext.labels = [];
} else {
const labelIndex = this.epicContext.labels.findIndex((l) => l.id === label.id);
if (labelIndex === -1) {
this.epicContext.labels.push(
new ListLabel({
id: label.id,
title: label.title,
color: label.color,
textColor: label.text_color,
}),
);
} else {
this.epicContext.labels.splice(labelIndex, 1);
}
}
},
handleLabelRemove(labelId) {
const labelToRemove = [{ id: labelId, set: false }];
this.updateEpicLabels(labelToRemove);
},
handleUpdateSelectedLabels(labels) {
// Iterate over selection and check if labels which were
// either selected or removed aren't leading to same selection
// as current one, as then we don't want to make network call
// since nothing has changed.
const anyLabelUpdated = labels.some((label) => {
// Find this label in existing selection.
const existingLabel = this.epicContext.labels.find((l) => l.id === label.id);
// Check either of the two following conditions;
// 1. A label that's not currently applied is being applied.
// 2. A label that's already applied is being removed.
return (!existingLabel && label.set) || (existingLabel && !label.set);
});
// Only proceed with action if there are any label updates to be done.
if (anyLabelUpdated) this.updateEpicLabels(labels);
},
},
};
</script>
<template>
<labels-select-vue
:allow-label-remove="canUpdate"
:allow-label-edit="canUpdate"
:allow-label-create="true"
:allow-multiselect="true"
:allow-scoped-labels="scopedLabels"
:selected-labels="labels"
:labels-select-in-progress="epicLabelsSelectInProgress"
:labels-fetch-path="labelsPath"
:labels-manage-path="labelsWebUrl"
:labels-filter-base-path="epicsWebUrl"
variant="sidebar"
class="block labels js-labels-block"
@updateSelectedLabels="handleUpdateSelectedLabels"
@onDropdownClose="handleDropdownClose"
@onLabelRemove="handleLabelRemove"
@toggleCollapse="toggleSidebarRevealLabelsDropdown"
>{{ __('None') }}</labels-select-vue
>
</template>
...@@ -19,7 +19,6 @@ export function initEpicForm() { ...@@ -19,7 +19,6 @@ export function initEpicForm() {
const { const {
groupPath, groupPath,
groupEpicsPath, groupEpicsPath,
labelsFetchPath,
labelsManagePath, labelsManagePath,
markdownDocsPath, markdownDocsPath,
markdownPreviewPath, markdownPreviewPath,
...@@ -33,7 +32,6 @@ export function initEpicForm() { ...@@ -33,7 +32,6 @@ export function initEpicForm() {
fullPath: groupPath, fullPath: groupPath,
allowLabelCreate: true, allowLabelCreate: true,
groupEpicsPath, groupEpicsPath,
labelsFetchPath,
labelsManagePath, labelsManagePath,
markdownDocsPath, markdownDocsPath,
markdownPreviewPath, markdownPreviewPath,
......
...@@ -25,7 +25,6 @@ describe('ee/epic/components/epic_form.vue', () => { ...@@ -25,7 +25,6 @@ describe('ee/epic/components/epic_form.vue', () => {
iid: '1', iid: '1',
groupPath: TEST_GROUP_PATH, groupPath: TEST_GROUP_PATH,
groupEpicsPath: TEST_HOST, groupEpicsPath: TEST_HOST,
labelsFetchPath: TEST_HOST,
labelsManagePath: TEST_HOST, labelsManagePath: TEST_HOST,
markdownPreviewPath: TEST_HOST, markdownPreviewPath: TEST_HOST,
markdownDocsPath: TEST_HOST, markdownDocsPath: TEST_HOST,
......
import { mount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
import SidebarLabels from 'ee/epic/components/sidebar_items/sidebar_labels.vue';
import createStore from 'ee/epic/store';
import { mockEpicMeta, mockEpicData, mockLabels } from '../../mock_data';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('SidebarLabelsComponent', () => {
let wrapper;
let store;
beforeEach(() => {
store = createStore();
store.dispatch('setEpicMeta', mockEpicMeta);
store.dispatch('setEpicData', mockEpicData);
wrapper = mount(SidebarLabels, {
propsData: { canUpdate: false, sidebarCollapsed: false },
store,
stubs: {
LabelsSelectVue: true,
GlLabel: true,
},
});
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('data', () => {
it('returns default data props', () => {
expect(wrapper.vm.sidebarExpandedOnClick).toBe(false);
});
});
describe('computed', () => {
describe('epicContext', () => {
it('returns object containing `this.labels` as a child prop', () => {
expect(wrapper.vm.epicContext.labels).toBe(wrapper.vm.labels);
});
});
});
describe('methods', () => {
describe('toggleSidebarRevealLabelsDropdown', () => {
it('calls `toggleSidebar` action with param `sidebarCollapse`', () => {
jest.spyOn(wrapper.vm, 'toggleSidebar');
wrapper.vm.toggleSidebarRevealLabelsDropdown();
expect(wrapper.vm.toggleSidebar).toHaveBeenCalledWith(
expect.objectContaining({
sidebarCollapsed: false,
}),
);
});
});
describe('handleDropdownClose', () => {
it('calls `toggleSidebar` action only when `sidebarExpandedOnClick` prop is true', () => {
jest.spyOn(wrapper.vm, 'toggleSidebar');
wrapper.setData({
sidebarExpandedOnClick: true,
});
wrapper.vm.handleDropdownClose();
expect(wrapper.vm.sidebarExpandedOnClick).toBe(false);
expect(wrapper.vm.toggleSidebar).toHaveBeenCalledWith(
expect.objectContaining({
sidebarCollapsed: false,
}),
);
});
});
describe('handleLabelClick', () => {
const label = {
id: 1,
title: 'Foo',
color: ['#BADA55'],
text_color: '#FFFFFF',
};
beforeEach(() => {
store.state.labels = mockLabels;
});
it('initializes `epicContext.labels` as empty array when `label.isAny` is `true`', () => {
const labelIsAny = { isAny: true };
wrapper.vm.handleLabelClick(labelIsAny);
expect(Array.isArray(wrapper.vm.epicContext.labels)).toBe(true);
expect(wrapper.vm.epicContext.labels).toHaveLength(0);
});
it('adds provided `label` to epicContext.labels', () => {
wrapper.vm.handleLabelClick(label);
// epicContext.labels gets initialized with initialLabels, hence
// newly insert label will be at second position (index `1`)
expect(wrapper.vm.epicContext.labels).toHaveLength(2);
expect(wrapper.vm.epicContext.labels[1].id).toBe(label.id);
wrapper.vm.handleLabelClick(label);
});
it('filters epicContext.labels to exclude provided `label` if it is already present in `epicContext.labels`', () => {
wrapper.vm.handleLabelClick(label); // Select
wrapper.vm.handleLabelClick(label); // Un-select
expect(wrapper.vm.epicContext.labels).toHaveLength(1);
expect(wrapper.vm.epicContext.labels[0].id).toBe(mockLabels[0].id);
});
});
describe('handleLabelRemove', () => {
it('calls action `updateEpicLabels` with the label ID to remove', () => {
const labelIdToRemove = 9;
jest.spyOn(wrapper.vm, 'updateEpicLabels').mockImplementation();
store.state.labels = mockLabels;
wrapper.vm.handleLabelRemove(labelIdToRemove);
expect(wrapper.vm.updateEpicLabels).toHaveBeenCalledWith(
expect.arrayContaining([{ id: labelIdToRemove, set: false }]),
);
});
});
describe('handleUpdateSelectedLabels', () => {
const updatingLabel = {
id: 1,
title: 'Foo Label',
description: 'Foobar',
color: '#BADA55',
text_color: '#FFFFFF',
};
beforeEach(() => {
jest.spyOn(wrapper.vm, 'updateEpicLabels').mockImplementation();
});
it('calls action `updateEpicLabels` when there is a label to apply', () => {
store.state.labels = mockLabels;
const appliedLabel = {
...updatingLabel,
set: true,
};
wrapper.vm.handleUpdateSelectedLabels([appliedLabel]);
expect(wrapper.vm.updateEpicLabels).toHaveBeenCalledWith(
expect.arrayContaining([appliedLabel]),
);
});
it('calls action `updateEpicLabels` when there is a label to remove', () => {
const removedLabel = {
...updatingLabel,
set: false,
};
store.state.labels = [...mockLabels, removedLabel];
wrapper.vm.handleUpdateSelectedLabels([removedLabel]);
expect(wrapper.vm.updateEpicLabels).toHaveBeenCalledWith(
expect.arrayContaining([removedLabel]),
);
});
it('does not call action `updateEpicLabels` when there are no labels to apply or remove', () => {
const appliedLabel = {
...updatingLabel,
set: true,
};
store.state.labels = [...mockLabels, appliedLabel];
wrapper.vm.handleUpdateSelectedLabels([appliedLabel]);
expect(wrapper.vm.updateEpicLabels).not.toHaveBeenCalled();
});
});
});
describe('template', () => {
it('renders labels select element container', () => {
expect(wrapper.classes('js-labels-block')).toBe(true);
});
});
});
...@@ -3650,9 +3650,6 @@ msgstr "" ...@@ -3650,9 +3650,6 @@ msgstr ""
msgid "An error occurred previewing the blob" msgid "An error occurred previewing the blob"
msgstr "" msgstr ""
msgid "An error occurred when removing the label."
msgstr ""
msgid "An error occurred when updating the title" msgid "An error occurred when updating the title"
msgstr "" msgstr ""
...@@ -7050,9 +7047,6 @@ msgstr "" ...@@ -7050,9 +7047,6 @@ msgstr ""
msgid "Choose file…" msgid "Choose file…"
msgstr "" msgstr ""
msgid "Choose labels"
msgstr ""
msgid "Choose specific groups or storage shards" msgid "Choose specific groups or storage shards"
msgstr "" msgstr ""
...@@ -19539,9 +19533,6 @@ msgstr "" ...@@ -19539,9 +19533,6 @@ msgstr ""
msgid "IssueAnalytics|Weight" msgid "IssueAnalytics|Weight"
msgstr "" msgstr ""
msgid "IssueBoards|An error occurred while setting notifications status. Please try again."
msgstr ""
msgid "IssueBoards|Board" msgid "IssueBoards|Board"
msgstr "" msgstr ""
......
...@@ -18,7 +18,7 @@ module QA ...@@ -18,7 +18,7 @@ module QA
element :more_assignees_link element :more_assignees_link
end end
base.view 'app/assets/javascripts/sidebar/components/labels/sidebar_labels.vue' do base.view 'app/assets/javascripts/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue' do
element :labels_block element :labels_block
end end
......
import { GlLabel } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { TEST_HOST } from 'helpers/test_constants';
import {
labels as TEST_LABELS,
mockIssue as TEST_ISSUE,
mockIssueFullPath as TEST_ISSUE_FULLPATH,
} from 'jest/boards/mock_data';
import BoardEditableItem from '~/boards/components/sidebar/board_editable_item.vue';
import BoardSidebarLabelsSelect from '~/boards/components/sidebar/board_sidebar_labels_select.vue';
import { createStore } from '~/boards/stores';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
const TEST_LABELS_PAYLOAD = TEST_LABELS.map((label) => ({ ...label, set: true }));
const TEST_LABELS_TITLES = TEST_LABELS.map((label) => label.title);
describe('~/boards/components/sidebar/board_sidebar_labels_select.vue', () => {
let wrapper;
let store;
afterEach(() => {
wrapper.destroy();
store = null;
wrapper = null;
});
const createWrapper = ({ labels = [], providedValues = {} } = {}) => {
store = createStore();
store.state.boardItems = { [TEST_ISSUE.id]: { ...TEST_ISSUE, labels } };
store.state.activeId = TEST_ISSUE.id;
wrapper = shallowMount(BoardSidebarLabelsSelect, {
store,
provide: {
canUpdate: true,
labelsManagePath: TEST_HOST,
labelsFilterBasePath: TEST_HOST,
...providedValues,
},
stubs: {
BoardEditableItem,
LabelsSelect: true,
},
});
};
const findLabelsSelect = () => wrapper.find({ ref: 'labelsSelect' });
const findLabelsTitles = () =>
wrapper.findAll(GlLabel).wrappers.map((item) => item.props('title'));
const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]');
describe('when labelsFetchPath is provided', () => {
it('uses injected labels fetch path', () => {
createWrapper({ providedValues: { labelsFetchPath: 'foobar' } });
expect(findLabelsSelect().props('labelsFetchPath')).toEqual('foobar');
});
});
it('uses the default project label endpoint', () => {
createWrapper();
expect(findLabelsSelect().props('labelsFetchPath')).toEqual(
`/${TEST_ISSUE_FULLPATH}/-/labels?include_ancestor_groups=true`,
);
});
it('renders "None" when no labels are selected', () => {
createWrapper();
expect(findCollapsed().text()).toBe('None');
});
it('renders labels when set', () => {
createWrapper({ labels: TEST_LABELS });
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
});
describe('when labels are submitted', () => {
beforeEach(async () => {
createWrapper();
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => TEST_LABELS);
findLabelsSelect().vm.$emit('updateSelectedLabels', TEST_LABELS_PAYLOAD);
store.state.boardItems[TEST_ISSUE.id].labels = TEST_LABELS;
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders labels', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
addLabelIds: TEST_LABELS.map((label) => label.id),
projectPath: TEST_ISSUE_FULLPATH,
removeLabelIds: [],
iid: null,
});
});
});
describe('when labels are updated over existing labels', () => {
const testLabelsPayload = [
{ id: 5, set: true },
{ id: 6, set: false },
{ id: 7, set: true },
];
const expectedLabels = [{ id: 5 }, { id: 7 }];
beforeEach(async () => {
createWrapper({ labels: TEST_LABELS });
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => expectedLabels);
findLabelsSelect().vm.$emit('updateSelectedLabels', testLabelsPayload);
await wrapper.vm.$nextTick();
});
it('commits change to the server', () => {
expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
addLabelIds: [5, 7],
removeLabelIds: [6],
projectPath: TEST_ISSUE_FULLPATH,
iid: null,
});
});
});
describe('when removing individual labels', () => {
const testLabel = TEST_LABELS[0];
beforeEach(async () => {
createWrapper({ labels: [testLabel] });
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {});
});
it('commits change to the server', () => {
wrapper.find(GlLabel).vm.$emit('close', testLabel);
expect(wrapper.vm.setActiveBoardItemLabels).toHaveBeenCalledWith({
removeLabelIds: [getIdFromGraphQLId(testLabel.id)],
projectPath: TEST_ISSUE_FULLPATH,
});
});
});
describe('when the mutation fails', () => {
beforeEach(async () => {
createWrapper({ labels: TEST_LABELS });
jest.spyOn(wrapper.vm, 'setActiveBoardItemLabels').mockImplementation(() => {
throw new Error(['failed mutation']);
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findLabelsSelect().vm.$emit('updateSelectedLabels', [{ id: '?' }]);
await wrapper.vm.$nextTick();
});
it('collapses sidebar and renders former issue weight', () => {
expect(findCollapsed().isVisible()).toBe(true);
expect(findLabelsTitles()).toEqual(TEST_LABELS_TITLES);
expect(wrapper.vm.setError).toHaveBeenCalled();
});
});
});
import { GlToggle, GlLoadingIcon } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Vue from 'vue';
import Vuex from 'vuex';
import BoardSidebarSubscription from '~/boards/components/sidebar/board_sidebar_subscription.vue';
import { createStore } from '~/boards/stores';
import * as types from '~/boards/stores/mutation_types';
import { mockActiveIssue } from '../../mock_data';
Vue.use(Vuex);
describe('~/boards/components/sidebar/board_sidebar_subscription_spec.vue', () => {
let wrapper;
let store;
const findNotificationHeader = () => wrapper.find("[data-testid='notification-header-text']");
const findToggle = () => wrapper.findComponent(GlToggle);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const createComponent = (activeBoardItem = { ...mockActiveIssue }) => {
store = createStore();
store.state.boardItems = { [activeBoardItem.id]: activeBoardItem };
store.state.activeId = activeBoardItem.id;
wrapper = mount(BoardSidebarSubscription, {
store,
provide: {
emailsDisabled: false,
},
});
};
afterEach(() => {
wrapper.destroy();
store = null;
jest.clearAllMocks();
});
describe('Board sidebar subscription component template', () => {
it('displays "notifications" heading', () => {
createComponent();
expect(findNotificationHeader().text()).toBe('Notifications');
});
it('renders toggle with label', () => {
createComponent();
expect(findToggle().props('label')).toBe(BoardSidebarSubscription.i18n.header.title);
});
it('renders toggle as "off" when currently not subscribed', () => {
createComponent();
expect(findToggle().exists()).toBe(true);
expect(findToggle().props('value')).toBe(false);
});
it('renders toggle as "on" when currently subscribed', () => {
createComponent({
...mockActiveIssue,
subscribed: true,
});
expect(findToggle().exists()).toBe(true);
expect(findToggle().props('value')).toBe(true);
});
describe('when notification emails have been disabled', () => {
beforeEach(() => {
createComponent({
...mockActiveIssue,
emailsDisabled: true,
});
});
it('displays a message that notification have been disabled', () => {
expect(findNotificationHeader().text()).toBe(
'Notifications have been disabled by the project or group owner',
);
});
it('does not render the toggle button', () => {
expect(findToggle().exists()).toBe(false);
});
});
});
describe('Board sidebar subscription component `behavior`', () => {
const mockSetActiveIssueSubscribed = (subscribedState) => {
jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => {
store.commit(types.UPDATE_BOARD_ITEM_BY_ID, {
itemId: mockActiveIssue.id,
prop: 'subscribed',
value: subscribedState,
});
});
};
it('subscribing to notification', async () => {
createComponent();
mockSetActiveIssueSubscribed(true);
expect(findGlLoadingIcon().exists()).toBe(false);
findToggle().vm.$emit('change');
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(true);
expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({
subscribed: true,
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findToggle().props('value')).toBe(true);
});
it('unsubscribing from notification', async () => {
createComponent({
...mockActiveIssue,
subscribed: true,
});
mockSetActiveIssueSubscribed(false);
expect(findGlLoadingIcon().exists()).toBe(false);
findToggle().vm.$emit('change');
await wrapper.vm.$nextTick();
expect(wrapper.vm.setActiveItemSubscribed).toHaveBeenCalledWith({
subscribed: false,
projectPath: 'gitlab-org/test-subgroup/gitlab-test',
});
expect(findGlLoadingIcon().exists()).toBe(true);
await wrapper.vm.$nextTick();
expect(findGlLoadingIcon().exists()).toBe(false);
expect(findToggle().props('value')).toBe(false);
});
it('flashes an error message when setting the subscribed state fails', async () => {
createComponent();
jest.spyOn(wrapper.vm, 'setActiveItemSubscribed').mockImplementation(async () => {
throw new Error();
});
jest.spyOn(wrapper.vm, 'setError').mockImplementation(() => {});
findToggle().vm.$emit('change');
await wrapper.vm.$nextTick();
expect(wrapper.vm.setError).toHaveBeenCalled();
expect(wrapper.vm.setError.mock.calls[0][0].message).toBe(
wrapper.vm.$options.i18n.updateSubscribedErrorMessage,
);
});
});
});
import { shallowMount } from '@vue/test-utils';
import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue';
import {
DropdownVariant,
LabelType,
} from '~/vue_shared/components/sidebar/labels_select_widget/constants';
import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
describe('sidebar labels', () => {
let wrapper;
const defaultProps = {
allowLabelEdit: true,
iid: '1',
issuableType: 'issue',
projectIssuesPath: '/gitlab-org/gitlab-test/-/issues',
fullPath: 'gitlab-org/gitlab-test',
};
const findLabelsSelect = () => wrapper.find(LabelsSelect);
const mountComponent = (props = {}) => {
wrapper = shallowMount(SidebarLabels, {
provide: {
...defaultProps,
...props,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('LabelsSelect props', () => {
describe.each`
issuableType
${'issue'}
${'merge_request'}
`('issuableType $issuableType', ({ issuableType }) => {
beforeEach(() => {
mountComponent({ issuableType });
});
it('has expected props', () => {
expect(findLabelsSelect().props()).toMatchObject({
iid: defaultProps.iid,
fullPath: defaultProps.fullPath,
allowLabelRemove: defaultProps.allowLabelEdit,
allowMultiselect: true,
footerCreateLabelTitle: 'Create project label',
footerManageLabelTitle: 'Manage project labels',
labelsCreateTitle: 'Create project label',
labelsFilterBasePath: defaultProps.projectIssuesPath,
variant: DropdownVariant.Sidebar,
issuableType,
workspaceType: 'project',
attrWorkspacePath: defaultProps.fullPath,
labelCreateType: LabelType.project,
});
});
});
});
});
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