Welcome to mirror list, hosted at ThFree Co, Russian Federation.

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-06-17 13:07:47 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-06-17 13:07:47 +0300
commitd670c3006e6e44901bce0d53cc4768d1d80ffa92 (patch)
tree8f65743c232e5b76850c4cc264ba15e1185815ff /spec/frontend
parenta5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (diff)
Add latest changes from gitlab-org/gitlab@14-0-stable-ee
Diffstat (limited to 'spec/frontend')
-rw-r--r--spec/frontend/content_editor/components/top_toolbar_spec.js1
-rw-r--r--spec/frontend/environments/deploy_board_component_spec.js1
-rw-r--r--spec/frontend/fixtures/api_markdown.yml4
-rw-r--r--spec/frontend/fixtures/releases.rb3
-rw-r--r--spec/frontend/issuable_list/components/issuable_list_root_spec.js128
-rw-r--r--spec/frontend/issues_list/components/issue_card_time_info_spec.js10
-rw-r--r--spec/frontend/issues_list/components/issues_list_app_spec.js278
-rw-r--r--spec/frontend/issues_list/mock_data.js67
-rw-r--r--spec/frontend/runner/components/runner_manual_setup_help_spec.js39
-rw-r--r--spec/frontend/runner/components/runner_registration_token_reset_spec.js155
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js22
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js31
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js173
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js357
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js72
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js88
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js84
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js241
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js93
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js176
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js140
25 files changed, 2159 insertions, 232 deletions
diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/top_toolbar_spec.js
index 0a1405a1774..0d55fa730ae 100644
--- a/spec/frontend/content_editor/components/top_toolbar_spec.js
+++ b/spec/frontend/content_editor/components/top_toolbar_spec.js
@@ -42,6 +42,7 @@ describe('content_editor/components/top_toolbar', () => {
testId | controlProps
${'bold'} | ${{ contentType: 'bold', iconName: 'bold', label: 'Bold text', editorCommand: 'toggleBold' }}
${'italic'} | ${{ contentType: 'italic', iconName: 'italic', label: 'Italic text', editorCommand: 'toggleItalic' }}
+ ${'strike'} | ${{ contentType: 'strike', iconName: 'strikethrough', label: 'Strikethrough', editorCommand: 'toggleStrike' }}
${'code'} | ${{ contentType: 'code', iconName: 'code', label: 'Code', editorCommand: 'toggleCode' }}
${'blockquote'} | ${{ contentType: 'blockquote', iconName: 'quote', label: 'Insert a quote', editorCommand: 'toggleBlockquote' }}
${'bullet-list'} | ${{ contentType: 'bulletList', iconName: 'list-bulleted', label: 'Add a bullet list', editorCommand: 'toggleBulletList' }}
diff --git a/spec/frontend/environments/deploy_board_component_spec.js b/spec/frontend/environments/deploy_board_component_spec.js
index 53220341a62..24e94867afd 100644
--- a/spec/frontend/environments/deploy_board_component_spec.js
+++ b/spec/frontend/environments/deploy_board_component_spec.js
@@ -12,7 +12,6 @@ describe('Deploy Board', () => {
const createComponent = (props = {}) =>
mount(Vue.extend(DeployBoard), {
- provide: { glFeatures: { canaryIngressWeightControl: true } },
propsData: {
deployBoardData: deployBoardMockData,
isLoading: false,
diff --git a/spec/frontend/fixtures/api_markdown.yml b/spec/frontend/fixtures/api_markdown.yml
index a1ea2806879..3274e914f03 100644
--- a/spec/frontend/fixtures/api_markdown.yml
+++ b/spec/frontend/fixtures/api_markdown.yml
@@ -1,6 +1,6 @@
# This data file drives the specs in
# spec/frontend/fixtures/api_markdown.rb and
-# spec/frontend/rich_text_editor/extensions/markdown_processing_spec.js
+# spec/frontend/content_editor/extensions/markdown_processing_spec.js
---
- name: bold
markdown: '**bold**'
@@ -8,6 +8,8 @@
markdown: '_emphasized text_'
- name: inline_code
markdown: '`code`'
+- name: strike
+ markdown: '~~del~~'
- name: link
markdown: '[GitLab](https://gitlab.com)'
- name: code_block
diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb
index 1882ac49fd6..ac34400bc01 100644
--- a/spec/frontend/fixtures/releases.rb
+++ b/spec/frontend/fixtures/releases.rb
@@ -146,6 +146,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :releases)).to be_present
end
it "graphql/#{one_release_query_path}.json" do
@@ -154,6 +155,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :release)).to be_present
end
it "graphql/#{one_release_for_editing_query_path}.json" do
@@ -162,6 +164,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do
post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag })
expect_graphql_errors_to_be_empty
+ expect(graphql_data_at(:project, :release)).to be_present
end
end
end
diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
index 38d6d6d86bc..7dddd2c3405 100644
--- a/spec/frontend/issuable_list/components/issuable_list_root_spec.js
+++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js
@@ -1,4 +1,4 @@
-import { GlSkeletonLoading, GlPagination } from '@gitlab/ui';
+import { GlKeysetPagination, GlSkeletonLoading, GlPagination } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import VueDraggable from 'vuedraggable';
@@ -11,9 +11,12 @@ import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filte
import { mockIssuableListProps, mockIssuables } from '../mock_data';
-const createComponent = ({ props = mockIssuableListProps, data = {} } = {}) =>
+const createComponent = ({ props = {}, data = {} } = {}) =>
shallowMount(IssuableListRoot, {
- propsData: props,
+ propsData: {
+ ...mockIssuableListProps,
+ ...props,
+ },
data() {
return data;
},
@@ -34,6 +37,7 @@ describe('IssuableListRoot', () => {
let wrapper;
const findFilteredSearchBar = () => wrapper.findComponent(FilteredSearchBar);
+ const findGlKeysetPagination = () => wrapper.findComponent(GlKeysetPagination);
const findGlPagination = () => wrapper.findComponent(GlPagination);
const findIssuableItem = () => wrapper.findComponent(IssuableItem);
const findIssuableTabs = () => wrapper.findComponent(IssuableTabs);
@@ -189,15 +193,15 @@ describe('IssuableListRoot', () => {
});
describe('template', () => {
- beforeEach(() => {
+ it('renders component container element with class "issuable-list-container"', () => {
wrapper = createComponent();
- });
- it('renders component container element with class "issuable-list-container"', () => {
expect(wrapper.classes()).toContain('issuable-list-container');
});
it('renders issuable-tabs component', () => {
+ wrapper = createComponent();
+
const tabsEl = findIssuableTabs();
expect(tabsEl.exists()).toBe(true);
@@ -209,6 +213,8 @@ describe('IssuableListRoot', () => {
});
it('renders contents for slot "nav-actions" within issuable-tab component', () => {
+ wrapper = createComponent();
+
const buttonEl = findIssuableTabs().find('button.js-new-issuable');
expect(buttonEl.exists()).toBe(true);
@@ -216,6 +222,8 @@ describe('IssuableListRoot', () => {
});
it('renders filtered-search-bar component', () => {
+ wrapper = createComponent();
+
const searchEl = findFilteredSearchBar();
const {
namespace,
@@ -239,12 +247,8 @@ describe('IssuableListRoot', () => {
});
});
- it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => {
- wrapper.setProps({
- issuablesLoading: true,
- });
-
- await wrapper.vm.$nextTick();
+ it('renders gl-loading-icon when `issuablesLoading` prop is true', () => {
+ wrapper = createComponent({ props: { issuablesLoading: true } });
expect(wrapper.findAllComponents(GlSkeletonLoading)).toHaveLength(
wrapper.vm.skeletonItemCount,
@@ -252,6 +256,8 @@ describe('IssuableListRoot', () => {
});
it('renders issuable-item component for each item within `issuables` array', () => {
+ wrapper = createComponent();
+
const itemsEl = wrapper.findAllComponents(IssuableItem);
const mockIssuable = mockIssuableListProps.issuables[0];
@@ -262,28 +268,23 @@ describe('IssuableListRoot', () => {
});
});
- it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => {
- wrapper.setProps({
- issuables: [],
- });
-
- await wrapper.vm.$nextTick();
+ it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', () => {
+ wrapper = createComponent({ props: { issuables: [] } });
expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true);
expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state');
});
- it('renders gl-pagination when `showPaginationControls` prop is true', async () => {
- wrapper.setProps({
- showPaginationControls: true,
- totalItems: 10,
+ it('renders only gl-pagination when `showPaginationControls` prop is true', () => {
+ wrapper = createComponent({
+ props: {
+ showPaginationControls: true,
+ totalItems: 10,
+ },
});
- await wrapper.vm.$nextTick();
-
- const paginationEl = findGlPagination();
- expect(paginationEl.exists()).toBe(true);
- expect(paginationEl.props()).toMatchObject({
+ expect(findGlKeysetPagination().exists()).toBe(false);
+ expect(findGlPagination().props()).toMatchObject({
perPage: 20,
value: 1,
prevPage: 0,
@@ -292,32 +293,47 @@ describe('IssuableListRoot', () => {
align: 'center',
});
});
- });
- describe('events', () => {
- beforeEach(() => {
+ it('renders only gl-keyset-pagination when `showPaginationControls` and `useKeysetPagination` props are true', () => {
wrapper = createComponent({
- data: {
- checkedIssuables: {
- [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
- },
+ props: {
+ hasNextPage: true,
+ hasPreviousPage: true,
+ showPaginationControls: true,
+ useKeysetPagination: true,
},
});
+
+ expect(findGlPagination().exists()).toBe(false);
+ expect(findGlKeysetPagination().props()).toMatchObject({
+ hasNextPage: true,
+ hasPreviousPage: true,
+ });
});
+ });
+
+ describe('events', () => {
+ const data = {
+ checkedIssuables: {
+ [mockIssuables[0].iid]: { checked: true, issuable: mockIssuables[0] },
+ },
+ };
it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => {
+ wrapper = createComponent({ data });
+
findIssuableTabs().vm.$emit('click');
expect(wrapper.emitted('click-tab')).toBeTruthy();
});
- it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', async () => {
+ it('sets all issuables as checked when filtered-search-bar component emits `checked-input` event', () => {
+ wrapper = createComponent({ data });
+
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('checked-input', true);
- await wrapper.vm.$nextTick();
-
expect(searchEl.emitted('checked-input')).toBeTruthy();
expect(searchEl.emitted('checked-input').length).toBe(1);
@@ -328,6 +344,8 @@ describe('IssuableListRoot', () => {
});
it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => {
+ wrapper = createComponent({ data });
+
const searchEl = findFilteredSearchBar();
searchEl.vm.$emit('onFilter');
@@ -336,13 +354,13 @@ describe('IssuableListRoot', () => {
expect(wrapper.emitted('sort')).toBeTruthy();
});
- it('sets an issuable as checked when issuable-item component emits `checked-input` event', async () => {
+ it('sets an issuable as checked when issuable-item component emits `checked-input` event', () => {
+ wrapper = createComponent({ data });
+
const issuableItem = wrapper.findAllComponents(IssuableItem).at(0);
issuableItem.vm.$emit('checked-input', true);
- await wrapper.vm.$nextTick();
-
expect(issuableItem.emitted('checked-input')).toBeTruthy();
expect(issuableItem.emitted('checked-input').length).toBe(1);
@@ -353,27 +371,45 @@ describe('IssuableListRoot', () => {
});
it('emits `update-legacy-bulk-edit` when filtered-search-bar checkbox is checked', () => {
+ wrapper = createComponent({ data });
+
findFilteredSearchBar().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
it('emits `update-legacy-bulk-edit` when issuable-item checkbox is checked', () => {
+ wrapper = createComponent({ data });
+
findIssuableItem().vm.$emit('checked-input');
expect(wrapper.emitted('update-legacy-bulk-edit')).toEqual([[]]);
});
- it('gl-pagination component emits `page-change` event on `input` event', async () => {
- wrapper.setProps({
- showPaginationControls: true,
- });
-
- await wrapper.vm.$nextTick();
+ it('gl-pagination component emits `page-change` event on `input` event', () => {
+ wrapper = createComponent({ data, props: { showPaginationControls: true } });
findGlPagination().vm.$emit('input');
expect(wrapper.emitted('page-change')).toBeTruthy();
});
+
+ it.each`
+ event | glKeysetPaginationEvent
+ ${'next-page'} | ${'next'}
+ ${'previous-page'} | ${'prev'}
+ `(
+ 'emits `$event` event when gl-keyset-pagination emits `$glKeysetPaginationEvent` event',
+ ({ event, glKeysetPaginationEvent }) => {
+ wrapper = createComponent({
+ data,
+ props: { showPaginationControls: true, useKeysetPagination: true },
+ });
+
+ findGlKeysetPagination().vm.$emit(glKeysetPaginationEvent);
+
+ expect(wrapper.emitted(event)).toEqual([[]]);
+ },
+ );
});
describe('manual sorting', () => {
diff --git a/spec/frontend/issues_list/components/issue_card_time_info_spec.js b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
index 614ad586ec9..634687e77ab 100644
--- a/spec/frontend/issues_list/components/issue_card_time_info_spec.js
+++ b/spec/frontend/issues_list/components/issue_card_time_info_spec.js
@@ -13,12 +13,10 @@ describe('IssuesListApp component', () => {
dueDate: '2020-12-17',
startDate: '2020-12-10',
title: 'My milestone',
- webUrl: '/milestone/webUrl',
+ webPath: '/milestone/webPath',
},
dueDate: '2020-12-12',
- timeStats: {
- humanTimeEstimate: '1w',
- },
+ humanTimeEstimate: '1w',
};
const findMilestone = () => wrapper.find('[data-testid="issuable-milestone"]');
@@ -56,7 +54,7 @@ describe('IssuesListApp component', () => {
expect(milestone.text()).toBe(issue.milestone.title);
expect(milestone.find(GlIcon).props('name')).toBe('clock');
- expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webUrl);
+ expect(milestone.find(GlLink).attributes('href')).toBe(issue.milestone.webPath);
});
describe.each`
@@ -102,7 +100,7 @@ describe('IssuesListApp component', () => {
const timeEstimate = wrapper.find('[data-testid="time-estimate"]');
- expect(timeEstimate.text()).toBe(issue.timeStats.humanTimeEstimate);
+ expect(timeEstimate.text()).toBe(issue.humanTimeEstimate);
expect(timeEstimate.attributes('title')).toBe('Estimate');
expect(timeEstimate.find(GlIcon).props('name')).toBe('timer');
});
diff --git a/spec/frontend/issues_list/components/issues_list_app_spec.js b/spec/frontend/issues_list/components/issues_list_app_spec.js
index d78a436c618..a3ac57ee1bb 100644
--- a/spec/frontend/issues_list/components/issues_list_app_spec.js
+++ b/spec/frontend/issues_list/components/issues_list_app_spec.js
@@ -1,9 +1,19 @@
import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui';
-import { mount, shallowMount } from '@vue/test-utils';
+import { createLocalVue, mount, shallowMount } from '@vue/test-utils';
import AxiosMockAdapter from 'axios-mock-adapter';
+import { cloneDeep } from 'lodash';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import getIssuesQuery from 'ee_else_ce/issues_list/queries/get_issues.query.graphql';
+import createMockApollo from 'helpers/mock_apollo_helper';
import { TEST_HOST } from 'helpers/test_constants';
import waitForPromises from 'helpers/wait_for_promises';
-import { apiParams, filteredTokens, locationSearch, urlParams } from 'jest/issues_list/mock_data';
+import {
+ getIssuesQueryResponse,
+ filteredTokens,
+ locationSearch,
+ urlParams,
+} from 'jest/issues_list/mock_data';
import createFlash from '~/flash';
import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue';
import IssuableByEmail from '~/issuable/components/issuable_by_email.vue';
@@ -14,10 +24,7 @@ import {
apiSortParams,
CREATED_DESC,
DUE_DATE_OVERDUE,
- PAGE_SIZE,
- PAGE_SIZE_MANUAL,
PARAM_DUE_DATE,
- RELATIVE_POSITION_DESC,
TOKEN_TYPE_ASSIGNEE,
TOKEN_TYPE_AUTHOR,
TOKEN_TYPE_CONFIDENTIAL,
@@ -32,20 +39,26 @@ import {
import eventHub from '~/issues_list/eventhub';
import { getSortOptions } from '~/issues_list/utils';
import axios from '~/lib/utils/axios_utils';
+import { scrollUp } from '~/lib/utils/scroll_utils';
import { setUrlParams } from '~/lib/utils/url_utility';
jest.mock('~/flash');
+jest.mock('~/lib/utils/scroll_utils', () => ({
+ scrollUp: jest.fn().mockName('scrollUpMock'),
+}));
describe('IssuesListApp component', () => {
let axiosMock;
let wrapper;
+ const localVue = createLocalVue();
+ localVue.use(VueApollo);
+
const defaultProvide = {
autocompleteUsersPath: 'autocomplete/users/path',
calendarPath: 'calendar/path',
canBulkUpdate: false,
emptyStateSvgPath: 'empty-state.svg',
- endpoint: 'api/endpoint',
exportCsvPath: 'export/csv/path',
hasBlockedIssuesFeature: true,
hasIssueWeightsFeature: true,
@@ -61,21 +74,13 @@ describe('IssuesListApp component', () => {
signInPath: 'sign/in/path',
};
- const state = 'opened';
- const xPage = 1;
- const xTotal = 25;
- const tabCounts = {
- opened: xTotal,
- closed: undefined,
- all: undefined,
- };
- const fetchIssuesResponse = {
- data: [],
- headers: {
- 'x-page': xPage,
- 'x-total': xTotal,
- },
- };
+ let defaultQueryResponse = getIssuesQueryResponse;
+ if (IS_EE) {
+ defaultQueryResponse = cloneDeep(getIssuesQueryResponse);
+ defaultQueryResponse.data.project.issues.nodes[0].blockedByCount = 1;
+ defaultQueryResponse.data.project.issues.nodes[0].healthStatus = null;
+ defaultQueryResponse.data.project.issues.nodes[0].weight = 5;
+ }
const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons);
const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail);
@@ -86,19 +91,26 @@ describe('IssuesListApp component', () => {
const findGlLink = () => wrapper.findComponent(GlLink);
const findIssuableList = () => wrapper.findComponent(IssuableList);
- const mountComponent = ({ provide = {}, mountFn = shallowMount } = {}) =>
- mountFn(IssuesListApp, {
+ const mountComponent = ({
+ provide = {},
+ response = defaultQueryResponse,
+ mountFn = shallowMount,
+ } = {}) => {
+ const requestHandlers = [[getIssuesQuery, jest.fn().mockResolvedValue(response)]];
+ const apolloProvider = createMockApollo(requestHandlers);
+
+ return mountFn(IssuesListApp, {
+ localVue,
+ apolloProvider,
provide: {
...defaultProvide,
...provide,
},
});
+ };
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
- axiosMock
- .onGet(defaultProvide.endpoint)
- .reply(200, fetchIssuesResponse.data, fetchIssuesResponse.headers);
});
afterEach(() => {
@@ -108,28 +120,37 @@ describe('IssuesListApp component', () => {
});
describe('IssuableList', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent();
- await waitForPromises();
+ jest.runOnlyPendingTimers();
});
it('renders', () => {
expect(findIssuableList().props()).toMatchObject({
namespace: defaultProvide.projectPath,
recentSearchesStorageKey: 'issues',
- searchInputPlaceholder: 'Search or filter results…',
+ searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder,
sortOptions: getSortOptions(true, true),
initialSortBy: CREATED_DESC,
+ issuables: getIssuesQueryResponse.data.project.issues.nodes,
tabs: IssuableListTabs,
currentTab: IssuableStates.Opened,
- tabCounts,
- showPaginationControls: false,
- issuables: [],
- totalItems: xTotal,
- currentPage: xPage,
- previousPage: xPage - 1,
- nextPage: xPage + 1,
- urlParams: { page: xPage, state },
+ tabCounts: {
+ opened: 1,
+ closed: undefined,
+ all: undefined,
+ },
+ issuablesLoading: false,
+ isManualOrdering: false,
+ showBulkEditSidebar: false,
+ showPaginationControls: true,
+ useKeysetPagination: true,
+ hasPreviousPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasPreviousPage,
+ hasNextPage: getIssuesQueryResponse.data.project.issues.pageInfo.hasNextPage,
+ urlParams: {
+ state: IssuableStates.Opened,
+ ...urlSortParams[CREATED_DESC],
+ },
});
});
});
@@ -157,9 +178,9 @@ describe('IssuesListApp component', () => {
describe('csv import/export component', () => {
describe('when user is signed in', () => {
- it('renders', async () => {
- const search = '?page=1&search=refactor&state=opened&sort=created_date';
+ const search = '?search=refactor&state=opened&sort=created_date';
+ beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${search}` });
wrapper = mountComponent({
@@ -167,11 +188,13 @@ describe('IssuesListApp component', () => {
mountFn: mount,
});
- await waitForPromises();
+ jest.runOnlyPendingTimers();
+ });
+ it('renders', () => {
expect(findCsvImportExportButtons().props()).toMatchObject({
exportCsvPath: `${defaultProvide.exportCsvPath}${search}`,
- issuableCount: xTotal,
+ issuableCount: 1,
});
});
});
@@ -238,18 +261,6 @@ describe('IssuesListApp component', () => {
});
});
- describe('page', () => {
- it('is set from the url params', () => {
- const page = 5;
-
- global.jsdom.reconfigure({ url: setUrlParams({ page }, TEST_HOST) });
-
- wrapper = mountComponent();
-
- expect(findIssuableList().props('currentPage')).toBe(page);
- });
- });
-
describe('search', () => {
it('is set from the url params', () => {
global.jsdom.reconfigure({ url: `${TEST_HOST}${locationSearch}` });
@@ -326,12 +337,10 @@ describe('IssuesListApp component', () => {
describe('empty states', () => {
describe('when there are issues', () => {
describe('when search returns no results', () => {
- beforeEach(async () => {
+ beforeEach(() => {
global.jsdom.reconfigure({ url: `${TEST_HOST}?search=no+results` });
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -344,10 +353,8 @@ describe('IssuesListApp component', () => {
});
describe('when "Open" tab has no issues', () => {
- beforeEach(async () => {
+ beforeEach(() => {
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -360,14 +367,12 @@ describe('IssuesListApp component', () => {
});
describe('when "Closed" tab has no issues', () => {
- beforeEach(async () => {
+ beforeEach(() => {
global.jsdom.reconfigure({
url: setUrlParams({ state: IssuableStates.Closed }, TEST_HOST),
});
wrapper = mountComponent({ provide: { hasProjectIssues: true }, mountFn: mount });
-
- await waitForPromises();
});
it('shows empty state', () => {
@@ -555,98 +560,70 @@ describe('IssuesListApp component', () => {
describe('events', () => {
describe('when "click-tab" event is emitted by IssuableList', () => {
beforeEach(() => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, fetchIssuesResponse.data, {
- 'x-page': 2,
- 'x-total': xTotal,
- });
-
wrapper = mountComponent();
findIssuableList().vm.$emit('click-tab', IssuableStates.Closed);
});
- it('makes API call to filter the list by the new state and resets the page to 1', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page: 1,
- state: IssuableStates.Closed,
- });
+ it('updates to the new tab', () => {
+ expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed);
});
});
- describe('when "page-change" event is emitted by IssuableList', () => {
- const data = [{ id: 10, title: 'title', state }];
- const page = 2;
- const totalItems = 21;
-
- beforeEach(async () => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, data, {
- 'x-page': page,
- 'x-total': totalItems,
- });
-
- wrapper = mountComponent();
-
- findIssuableList().vm.$emit('page-change', page);
-
- await waitForPromises();
- });
+ describe.each(['next-page', 'previous-page'])(
+ 'when "%s" event is emitted by IssuableList',
+ (event) => {
+ beforeEach(() => {
+ wrapper = mountComponent();
- it('fetches issues with expected params', () => {
- expect(axiosMock.history.get[1].params).toMatchObject({
- page,
- per_page: PAGE_SIZE,
- state,
- with_labels_details: true,
+ findIssuableList().vm.$emit(event);
});
- });
- it('updates IssuableList with response data', () => {
- expect(findIssuableList().props()).toMatchObject({
- issuables: data,
- totalItems,
- currentPage: page,
- previousPage: page - 1,
- nextPage: page + 1,
- urlParams: { page, state },
+ it('scrolls to the top', () => {
+ expect(scrollUp).toHaveBeenCalled();
});
- });
- });
+ },
+ );
describe('when "reorder" event is emitted by IssuableList', () => {
- const issueOne = { id: 1, iid: 101, title: 'Issue one' };
- const issueTwo = { id: 2, iid: 102, title: 'Issue two' };
- const issueThree = { id: 3, iid: 103, title: 'Issue three' };
- const issueFour = { id: 4, iid: 104, title: 'Issue four' };
- const issues = [issueOne, issueTwo, issueThree, issueFour];
-
- beforeEach(async () => {
- axiosMock.onGet(defaultProvide.endpoint).reply(200, issues, fetchIssuesResponse.headers);
- wrapper = mountComponent();
- await waitForPromises();
- });
-
- describe('when successful', () => {
- describe.each`
- description | issueToMove | oldIndex | newIndex | moveBeforeId | moveAfterId
- ${'to the beginning of the list'} | ${issueThree} | ${2} | ${0} | ${null} | ${issueOne.id}
- ${'down the list'} | ${issueOne} | ${0} | ${1} | ${issueTwo.id} | ${issueThree.id}
- ${'up the list'} | ${issueThree} | ${2} | ${1} | ${issueOne.id} | ${issueTwo.id}
- ${'to the end of the list'} | ${issueTwo} | ${1} | ${3} | ${issueFour.id} | ${null}
- `(
- 'when moving issue $description',
- ({ issueToMove, oldIndex, newIndex, moveBeforeId, moveAfterId }) => {
- it('makes API call to reorder the issue', async () => {
- findIssuableList().vm.$emit('reorder', { oldIndex, newIndex });
-
- await waitForPromises();
-
- expect(axiosMock.history.put[0]).toMatchObject({
- url: `${defaultProvide.issuesPath}/${issueToMove.iid}/reorder`,
- data: JSON.stringify({ move_before_id: moveBeforeId, move_after_id: moveAfterId }),
- });
- });
+ const issueOne = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/1',
+ iid: 101,
+ title: 'Issue one',
+ };
+ const issueTwo = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/2',
+ iid: 102,
+ title: 'Issue two',
+ };
+ const issueThree = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/3',
+ iid: 103,
+ title: 'Issue three',
+ };
+ const issueFour = {
+ ...defaultQueryResponse.data.project.issues.nodes[0],
+ id: 'gid://gitlab/Issue/4',
+ iid: 104,
+ title: 'Issue four',
+ };
+ const response = {
+ data: {
+ project: {
+ issues: {
+ ...defaultQueryResponse.data.project.issues,
+ nodes: [issueOne, issueTwo, issueThree, issueFour],
+ },
},
- );
+ },
+ };
+
+ beforeEach(() => {
+ wrapper = mountComponent({ response });
+ jest.runOnlyPendingTimers();
});
describe('when unsuccessful', () => {
@@ -664,21 +641,16 @@ describe('IssuesListApp component', () => {
describe('when "sort" event is emitted by IssuableList', () => {
it.each(Object.keys(apiSortParams))(
- 'fetches issues with correct params with payload `%s`',
+ 'updates to the new sort when payload is `%s`',
async (sortKey) => {
wrapper = mountComponent();
findIssuableList().vm.$emit('sort', sortKey);
- await waitForPromises();
+ jest.runOnlyPendingTimers();
+ await nextTick();
- expect(axiosMock.history.get[1].params).toEqual({
- page: xPage,
- per_page: sortKey === RELATIVE_POSITION_DESC ? PAGE_SIZE_MANUAL : PAGE_SIZE,
- state,
- with_labels_details: true,
- ...apiSortParams[sortKey],
- });
+ expect(findIssuableList().props('urlParams')).toMatchObject(urlSortParams[sortKey]);
},
);
});
@@ -687,13 +659,11 @@ describe('IssuesListApp component', () => {
beforeEach(() => {
wrapper = mountComponent();
jest.spyOn(eventHub, '$emit');
- });
- it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', async () => {
findIssuableList().vm.$emit('update-legacy-bulk-edit');
+ });
- await waitForPromises();
-
+ it('emits an "issuables:updateBulkEdit" event to the legacy bulk edit class', () => {
expect(eventHub.$emit).toHaveBeenCalledWith('issuables:updateBulkEdit');
});
});
@@ -705,10 +675,6 @@ describe('IssuesListApp component', () => {
findIssuableList().vm.$emit('filter', filteredTokens);
});
- it('makes an API call to search for issues with the search term', () => {
- expect(axiosMock.history.get[1].params).toMatchObject(apiParams);
- });
-
it('updates IssuableList with url params', () => {
expect(findIssuableList().props('urlParams')).toMatchObject(urlParams);
});
diff --git a/spec/frontend/issues_list/mock_data.js b/spec/frontend/issues_list/mock_data.js
index 99267fb6e31..6c669e02070 100644
--- a/spec/frontend/issues_list/mock_data.js
+++ b/spec/frontend/issues_list/mock_data.js
@@ -3,6 +3,73 @@ import {
OPERATOR_IS_NOT,
} from '~/vue_shared/components/filtered_search_bar/constants';
+export const getIssuesQueryResponse = {
+ data: {
+ project: {
+ issues: {
+ count: 1,
+ pageInfo: {
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: 'startcursor',
+ endCursor: 'endcursor',
+ },
+ nodes: [
+ {
+ id: 'gid://gitlab/Issue/123456',
+ iid: '789',
+ closedAt: null,
+ confidential: false,
+ createdAt: '2021-05-22T04:08:01Z',
+ downvotes: 2,
+ dueDate: '2021-05-29',
+ humanTimeEstimate: null,
+ moved: false,
+ title: 'Issue title',
+ updatedAt: '2021-05-22T04:08:01Z',
+ upvotes: 3,
+ userDiscussionsCount: 4,
+ webUrl: 'project/-/issues/789',
+ assignees: {
+ nodes: [
+ {
+ id: 'gid://gitlab/User/234',
+ avatarUrl: 'avatar/url',
+ name: 'Marge Simpson',
+ username: 'msimpson',
+ webUrl: 'url/msimpson',
+ },
+ ],
+ },
+ author: {
+ id: 'gid://gitlab/User/456',
+ avatarUrl: 'avatar/url',
+ name: 'Homer Simpson',
+ username: 'hsimpson',
+ webUrl: 'url/hsimpson',
+ },
+ labels: {
+ nodes: [
+ {
+ id: 'gid://gitlab/ProjectLabel/456',
+ color: '#333',
+ title: 'Label title',
+ description: 'Label description',
+ },
+ ],
+ },
+ milestone: null,
+ taskCompletionStatus: {
+ completedCount: 1,
+ count: 2,
+ },
+ },
+ ],
+ },
+ },
+ },
+};
+
export const locationSearch = [
'?search=find+issues',
'author_username=homer',
diff --git a/spec/frontend/runner/components/runner_manual_setup_help_spec.js b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
index ca5c88f6e28..add595d784e 100644
--- a/spec/frontend/runner/components/runner_manual_setup_help_spec.js
+++ b/spec/frontend/runner/components/runner_manual_setup_help_spec.js
@@ -1,8 +1,11 @@
import { GlSprintf } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
import { TEST_HOST } from 'helpers/test_constants';
import { extendedWrapper } from 'helpers/vue_test_utils_helper';
import RunnerManualSetupHelp from '~/runner/components/runner_manual_setup_help.vue';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
+import { INSTANCE_TYPE, GROUP_TYPE, PROJECT_TYPE } from '~/runner/constants';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import RunnerInstructions from '~/vue_shared/components/runner_instructions/runner_instructions.vue';
@@ -14,6 +17,8 @@ describe('RunnerManualSetupHelp', () => {
let originalGon;
const findRunnerInstructions = () => wrapper.findComponent(RunnerInstructions);
+ const findRunnerRegistrationTokenReset = () =>
+ wrapper.findComponent(RunnerRegistrationTokenReset);
const findClipboardButtons = () => wrapper.findAllComponents(ClipboardButton);
const findRunnerHelpTitle = () => wrapper.findByTestId('runner-help-title');
const findCoordinatorUrl = () => wrapper.findByTestId('coordinator-url');
@@ -28,6 +33,7 @@ describe('RunnerManualSetupHelp', () => {
},
propsData: {
registrationToken: mockRegistrationToken,
+ type: INSTANCE_TYPE,
...props,
},
stubs: {
@@ -54,16 +60,26 @@ describe('RunnerManualSetupHelp', () => {
wrapper.destroy();
});
- it('Title contains the default runner type', () => {
+ it('Title contains the shared runner type', () => {
+ createComponent({ props: { type: INSTANCE_TYPE } });
+
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a shared runner manually');
});
it('Title contains the group runner type', () => {
- createComponent({ props: { typeName: 'group' } });
+ createComponent({ props: { type: GROUP_TYPE } });
expect(findRunnerHelpTitle().text()).toMatchInterpolatedText('Set up a group runner manually');
});
+ it('Title contains the specific runner type', () => {
+ createComponent({ props: { type: PROJECT_TYPE } });
+
+ expect(findRunnerHelpTitle().text()).toMatchInterpolatedText(
+ 'Set up a specific runner manually',
+ );
+ });
+
it('Runner Install Page link', () => {
expect(findRunnerHelpLink().attributes('href')).toBe(mockRunnerInstallHelpPage);
});
@@ -73,12 +89,27 @@ describe('RunnerManualSetupHelp', () => {
expect(findClipboardButtons().at(0).props('text')).toBe(TEST_HOST);
});
+ it('Displays the runner instructions', () => {
+ expect(findRunnerInstructions().exists()).toBe(true);
+ });
+
it('Displays the registration token', () => {
expect(findRegistrationToken().text()).toBe(mockRegistrationToken);
expect(findClipboardButtons().at(1).props('text')).toBe(mockRegistrationToken);
});
- it('Displays the runner instructions', () => {
- expect(findRunnerInstructions().exists()).toBe(true);
+ it('Displays the runner registration token reset button', () => {
+ expect(findRunnerRegistrationTokenReset().exists()).toBe(true);
+ });
+
+ it('Replaces the runner reset button', async () => {
+ const mockNewRegistrationToken = 'NEW_MOCK_REGISTRATION_TOKEN';
+
+ findRunnerRegistrationTokenReset().vm.$emit('tokenReset', mockNewRegistrationToken);
+
+ await nextTick();
+
+ expect(findRegistrationToken().text()).toBe(mockNewRegistrationToken);
+ expect(findClipboardButtons().at(1).props('text')).toBe(mockNewRegistrationToken);
});
});
diff --git a/spec/frontend/runner/components/runner_registration_token_reset_spec.js b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
new file mode 100644
index 00000000000..fa5751b380f
--- /dev/null
+++ b/spec/frontend/runner/components/runner_registration_token_reset_spec.js
@@ -0,0 +1,155 @@
+import { GlButton } from '@gitlab/ui';
+import { createLocalVue, shallowMount } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import createFlash, { FLASH_TYPES } from '~/flash';
+import RunnerRegistrationTokenReset from '~/runner/components/runner_registration_token_reset.vue';
+import { INSTANCE_TYPE } from '~/runner/constants';
+import runnersRegistrationTokenResetMutation from '~/runner/graphql/runners_registration_token_reset.mutation.graphql';
+
+jest.mock('~/flash');
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+const mockNewToken = 'NEW_TOKEN';
+
+describe('RunnerRegistrationTokenReset', () => {
+ let wrapper;
+ let runnersRegistrationTokenResetMutationHandler;
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ const createComponent = () => {
+ wrapper = shallowMount(RunnerRegistrationTokenReset, {
+ localVue,
+ propsData: {
+ type: INSTANCE_TYPE,
+ },
+ apolloProvider: createMockApollo([
+ [runnersRegistrationTokenResetMutation, runnersRegistrationTokenResetMutationHandler],
+ ]),
+ });
+ };
+
+ beforeEach(() => {
+ runnersRegistrationTokenResetMutationHandler = jest.fn().mockResolvedValue({
+ data: {
+ runnersRegistrationTokenReset: {
+ token: mockNewToken,
+ errors: [],
+ },
+ },
+ });
+
+ createComponent();
+
+ jest.spyOn(window, 'confirm');
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('Displays reset button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ describe('On click and confirmation', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ });
+
+ it('resets token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledTimes(1);
+ expect(runnersRegistrationTokenResetMutationHandler).toHaveBeenCalledWith({
+ input: { type: INSTANCE_TYPE },
+ });
+ });
+
+ it('emits result', () => {
+ expect(wrapper.emitted('tokenReset')).toHaveLength(1);
+ expect(wrapper.emitted('tokenReset')[0]).toEqual([mockNewToken]);
+ });
+
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+
+ it('shows confirmation', () => {
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: expect.stringContaining('registration token generated'),
+ type: FLASH_TYPES.SUCCESS,
+ });
+ });
+ });
+
+ describe('On click without confirmation', () => {
+ beforeEach(async () => {
+ window.confirm.mockReturnValueOnce(false);
+ await findButton().vm.$emit('click');
+ });
+
+ it('does not reset token', () => {
+ expect(runnersRegistrationTokenResetMutationHandler).not.toHaveBeenCalled();
+ });
+
+ it('does not emit any result', () => {
+ expect(wrapper.emitted('tokenReset')).toBeUndefined();
+ });
+
+ it('does not show a loading state', () => {
+ expect(findButton().props('loading')).toBe(false);
+ });
+
+ it('does not shows confirmation', () => {
+ expect(createFlash).not.toHaveBeenCalled();
+ });
+ });
+
+ describe('On error', () => {
+ it('On network error, error message is shown', async () => {
+ runnersRegistrationTokenResetMutationHandler.mockRejectedValueOnce(
+ new Error('Something went wrong'),
+ );
+
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Network error: Something went wrong',
+ });
+ });
+
+ it('On validation error, error message is shown', async () => {
+ runnersRegistrationTokenResetMutationHandler.mockResolvedValue({
+ data: {
+ runnersRegistrationTokenReset: {
+ token: null,
+ errors: ['Token reset failed'],
+ },
+ },
+ });
+
+ window.confirm.mockReturnValueOnce(true);
+ await findButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenLastCalledWith({
+ message: 'Token reset failed',
+ });
+ });
+ });
+
+ describe('Immediately after click', () => {
+ it('shows loading state', async () => {
+ window.confirm.mockReturnValue(true);
+ await findButton().vm.$emit('click');
+
+ expect(findButton().props('loading')).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
index f50eafdbc52..951b050495c 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js
@@ -46,6 +46,7 @@ function createComponent(options = {}) {
active = false,
stubs = defaultStubs,
data = {},
+ listeners = {},
} = options;
return mount(AuthorToken, {
propsData: {
@@ -62,6 +63,7 @@ function createComponent(options = {}) {
return { ...data };
},
stubs,
+ listeners,
});
}
@@ -258,6 +260,18 @@ describe('AuthorToken', () => {
expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text);
});
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
+
describe('when loading', () => {
beforeEach(() => {
wrapper = createComponent({
@@ -276,6 +290,14 @@ describe('AuthorToken', () => {
expect(firstSuggestion).toContain('Administrator');
expect(firstSuggestion).toContain('@root');
});
+
+ it('does not show current user while searching', async () => {
+ wrapper.findComponent(BaseToken).vm.handleInput({ data: 'foo' });
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.findComponent(GlFilteredSearchSuggestion).exists()).toBe(false);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
index 602864f4fa5..89c5cedc9b8 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -46,12 +46,11 @@ const defaultSlots = {
};
const mockProps = {
- tokenConfig: mockLabelToken,
- tokenValue: { data: '' },
- tokenActive: false,
- tokensListLoading: false,
+ config: mockLabelToken,
+ value: { data: '' },
+ active: false,
tokenValues: [],
- fnActiveTokenValue: jest.fn(),
+ tokensListLoading: false,
defaultTokenValues: DEFAULT_LABELS,
recentTokenValuesStorageKey: mockStorageKey,
fnCurrentTokenValue: jest.fn(),
@@ -83,7 +82,7 @@ describe('BaseToken', () => {
wrapper = createComponent({
props: {
...mockProps,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
tokenValues: mockLabels,
},
});
@@ -112,17 +111,17 @@ describe('BaseToken', () => {
describe('activeTokenValue', () => {
it('calls `fnActiveTokenValue` when it is provided', async () => {
+ const mockFnActiveTokenValue = jest.fn();
+
wrapper.setProps({
+ fnActiveTokenValue: mockFnActiveTokenValue,
fnCurrentTokenValue: undefined,
});
await wrapper.vm.$nextTick();
- // We're disabling lint to trigger computed prop execution for this test.
- // eslint-disable-next-line no-unused-vars
- const { activeTokenValue } = wrapper.vm;
-
- expect(wrapper.vm.fnActiveTokenValue).toHaveBeenCalledWith(
+ expect(mockFnActiveTokenValue).toHaveBeenCalledTimes(1);
+ expect(mockFnActiveTokenValue).toHaveBeenCalledWith(
mockLabels,
`"${mockRegularLabel.title.toLowerCase()}"`,
);
@@ -131,15 +130,15 @@ describe('BaseToken', () => {
});
describe('watch', () => {
- describe('tokenActive', () => {
+ describe('active', () => {
let wrapperWithTokenActive;
beforeEach(() => {
wrapperWithTokenActive = createComponent({
props: {
...mockProps,
- tokenActive: true,
- tokenValue: { data: `"${mockRegularLabel.title}"` },
+ value: { data: `"${mockRegularLabel.title}"` },
+ active: true,
},
});
});
@@ -150,7 +149,7 @@ describe('BaseToken', () => {
it('emits `fetch-token-values` event on the component when value of this prop is changed to false and `tokenValues` array is empty', async () => {
wrapperWithTokenActive.setProps({
- tokenActive: false,
+ active: false,
});
await wrapperWithTokenActive.vm.$nextTick();
@@ -238,7 +237,7 @@ describe('BaseToken', () => {
jest.runAllTimers();
expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy();
- expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']);
+ expect(wrapperWithNoStubs.emitted('fetch-token-values')[2]).toEqual(['foo']);
});
});
});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
index dd1c61b92b8..cc40ff96b65 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js
@@ -40,6 +40,7 @@ function createComponent(options = {}) {
value = { data: '' },
active = false,
stubs = defaultStubs,
+ listeners = {},
} = options;
return mount(LabelToken, {
propsData: {
@@ -53,6 +54,7 @@ function createComponent(options = {}) {
suggestionsListClass: 'custom-class',
},
stubs,
+ listeners,
});
}
@@ -206,7 +208,7 @@ describe('LabelToken', () => {
expect(wrapper.find(GlDropdownDivider).exists()).toBe(false);
});
- it('renders `DEFAULT_LABELS` as default suggestions', async () => {
+ it('renders `DEFAULT_LABELS` as default suggestions', () => {
wrapper = createComponent({
active: true,
config: { ...mockLabelToken },
@@ -215,7 +217,6 @@ describe('LabelToken', () => {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
suggestionsSegment.vm.$emit('activate');
- await wrapper.vm.$nextTick();
const suggestions = wrapper.findAll(GlFilteredSearchSuggestion);
@@ -224,5 +225,17 @@ describe('LabelToken', () => {
expect(suggestions.at(index).text()).toBe(label.text);
});
});
+
+ it('emits listeners in the base-token', () => {
+ const mockInput = jest.fn();
+ wrapper = createComponent({
+ listeners: {
+ input: mockInput,
+ },
+ });
+ wrapper.findComponent(BaseToken).vm.$emit('input', [{ data: 'mockData', operator: '=' }]);
+
+ expect(mockInput).toHaveBeenLastCalledWith([{ data: 'mockData', operator: '=' }]);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
new file mode 100644
index 00000000000..0a42d389b67
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_button_spec.js
@@ -0,0 +1,91 @@
+import { GlIcon, GlButton } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+
+import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+let store;
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ store = new Vuex.Store(labelSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownButton, {
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownButton', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const findDropdownButton = () => wrapper.find(GlButton);
+ const findDropdownText = () => wrapper.find('.dropdown-toggle-text');
+ const findDropdownIcon = () => wrapper.find(GlIcon);
+
+ describe('methods', () => {
+ describe('handleButtonClick', () => {
+ it.each`
+ variant | expectPropagationStopped
+ ${'standalone'} | ${true}
+ ${'embedded'} | ${false}
+ `(
+ 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"',
+ ({ variant, expectPropagationStopped }) => {
+ const event = { stopPropagation: jest.fn() };
+
+ wrapper = createComponent({ ...mockConfig, variant });
+
+ findDropdownButton().vm.$emit('click', event);
+
+ expect(store.state.showDropdownContents).toBe(true);
+ expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0);
+ },
+ );
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element', () => {
+ expect(wrapper.find(GlButton).element).toBe(wrapper.element);
+ });
+
+ it('renders default button text element', () => {
+ const dropdownTextEl = findDropdownText();
+
+ expect(dropdownTextEl.exists()).toBe(true);
+ expect(dropdownTextEl.text()).toBe('Label');
+ });
+
+ it('renders provided button text element', () => {
+ store.state.dropdownButtonText = 'Custom label';
+ const dropdownTextEl = findDropdownText();
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(dropdownTextEl.text()).toBe('Custom label');
+ });
+ });
+
+ it('renders chevron icon element', () => {
+ const iconEl = findDropdownIcon();
+
+ expect(iconEl.exists()).toBe(true);
+ expect(iconEl.props('name')).toBe('chevron-down');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
new file mode 100644
index 00000000000..46a11bc28d8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js
@@ -0,0 +1,173 @@
+import { GlLoadingIcon, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vue, { 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 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 { mockSuggestedColors, createLabelSuccessfulResponse } from './mock_data';
+
+jest.mock('~/flash');
+
+const colors = Object.keys(mockSuggestedColors);
+
+const localVue = createLocalVue();
+Vue.use(VueApollo);
+
+const userRecoverableError = {
+ ...createLabelSuccessfulResponse,
+ errors: ['Houston, we have a problem'],
+};
+
+const createLabelSuccessHandler = jest.fn().mockResolvedValue(createLabelSuccessfulResponse);
+const createLabelUserRecoverableErrorHandler = jest.fn().mockResolvedValue(userRecoverableError);
+const createLabelErrorHandler = jest.fn().mockRejectedValue('Houston, we have a problem');
+
+describe('DropdownContentsCreateView', () => {
+ let wrapper;
+
+ const findAllColors = () => wrapper.findAllComponents(GlLink);
+ const findSelectedColor = () => wrapper.find('[data-testid="selected-color"]');
+ const findSelectedColorText = () => wrapper.find('[data-testid="selected-color-text"]');
+ const findCreateButton = () => wrapper.find('[data-testid="create-button"]');
+ const findCancelButton = () => wrapper.find('[data-testid="cancel-button"]');
+ const findLabelTitleInput = () => wrapper.find('[data-testid="label-title-input"]');
+
+ const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+
+ const fillLabelAttributes = () => {
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ };
+
+ const createComponent = ({ mutationHandler = createLabelSuccessHandler } = {}) => {
+ const mockApollo = createMockApollo([[createLabelMutation, mutationHandler]]);
+
+ wrapper = shallowMount(DropdownContentsCreateView, {
+ localVue,
+ apolloProvider: mockApollo,
+ });
+ };
+
+ beforeEach(() => {
+ gon.suggested_label_colors = mockSuggestedColors;
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders a palette of 21 colors', () => {
+ createComponent();
+ expect(findAllColors()).toHaveLength(21);
+ });
+
+ it('selects a color after clicking on colored block', async () => {
+ createComponent();
+ expect(findSelectedColor().attributes('style')).toBeUndefined();
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColor().attributes('style')).toBe('background-color: rgb(0, 153, 102);');
+ });
+
+ it('shows correct color hex code after selecting a color', async () => {
+ createComponent();
+ expect(findSelectedColorText().attributes('value')).toBe('');
+
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findSelectedColorText().attributes('value')).toBe(colors[0]);
+ });
+
+ it('disables a Create button if label title is not set', async () => {
+ createComponent();
+ findAllColors().at(0).vm.$emit('click', new Event('mouseclick'));
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('disables a Create button if color is not set', async () => {
+ createComponent();
+ findLabelTitleInput().vm.$emit('input', 'Test title');
+ await nextTick();
+
+ expect(findCreateButton().props('disabled')).toBe(true);
+ });
+
+ it('does not render a loader spinner', () => {
+ createComponent();
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+
+ it('emits a `hideCreateView` event on Cancel button click', () => {
+ createComponent();
+ findCancelButton().vm.$emit('click');
+
+ expect(wrapper.emitted('hideCreateView')).toHaveLength(1);
+ });
+
+ describe('when label title and selected color are set', () => {
+ beforeEach(() => {
+ createComponent();
+ fillLabelAttributes();
+ });
+
+ it('enables a Create button', () => {
+ 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();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ });
+
+ it('does not loader spinner after mutation is resolved', async () => {
+ findCreateButton().vm.$emit('click');
+ await nextTick();
+
+ expect(findLoadingIcon().exists()).toBe(true);
+ await waitForPromises();
+
+ expect(findLoadingIcon().exists()).toBe(false);
+ });
+ });
+
+ it('calls createFlash is mutation has a user-recoverable error', async () => {
+ createComponent({ mutationHandler: createLabelUserRecoverableErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+
+ it('calls createFlash is mutation was rejected', async () => {
+ createComponent({ mutationHandler: createLabelErrorHandler });
+ fillLabelAttributes();
+ await nextTick();
+
+ findCreateButton().vm.$emit('click');
+ await waitForPromises();
+
+ expect(createFlash).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
new file mode 100644
index 00000000000..51301387c99
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js
@@ -0,0 +1,357 @@
+import { GlIntersectionObserver, GlLoadingIcon, GlSearchBoxByType, GlLink } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes';
+import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue';
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+import { mockConfig, mockLabels, mockRegularLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownContentsLabelsView', () => {
+ let wrapper;
+
+ const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store({
+ getters,
+ mutations,
+ state: {
+ ...defaultState(),
+ footerCreateLabelTitle: 'Create label',
+ footerManageLabelTitle: 'Manage labels',
+ },
+ actions: {
+ ...actions,
+ fetchLabels: jest.fn(),
+ },
+ });
+
+ store.dispatch('setInitialState', initialState);
+ store.dispatch('receiveLabelsSuccess', mockLabels);
+
+ wrapper = shallowMount(DropdownContentsLabelsView, {
+ localVue,
+ store,
+ });
+ };
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]');
+ const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]');
+ const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
+
+ describe('computed', () => {
+ describe('visibleLabels', () => {
+ it('returns matching labels filtered with `searchKey`', () => {
+ wrapper.setData({
+ searchKey: 'bug',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(1);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ });
+
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
+ it('returns all labels when `searchKey` is empty', () => {
+ wrapper.setData({
+ searchKey: '',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length);
+ });
+ });
+
+ describe('showNoMatchingResultsMessage', () => {
+ it.each`
+ searchKey | labels | labelsDescription | returnValue
+ ${''} | ${[]} | ${'empty'} | ${false}
+ ${'bug'} | ${[]} | ${'empty'} | ${true}
+ ${''} | ${mockLabels} | ${'not empty'} | ${false}
+ ${'bug'} | ${mockLabels} | ${'not empty'} | ${false}
+ `(
+ 'returns $returnValue when searchKey is "$searchKey" and visibleLabels is $labelsDescription',
+ async ({ searchKey, labels, returnValue }) => {
+ wrapper.setData({
+ searchKey,
+ });
+
+ wrapper.vm.$store.dispatch('receiveLabelsSuccess', labels);
+
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.vm.showNoMatchingResultsMessage).toBe(returnValue);
+ },
+ );
+ });
+ });
+
+ describe('methods', () => {
+ describe('isLabelSelected', () => {
+ it('returns true when provided `label` param is one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockRegularLabel)).toBe(true);
+ });
+
+ it('returns false when provided `label` param is not one of the selected labels', () => {
+ expect(wrapper.vm.isLabelSelected(mockLabels[2])).toBe(false);
+ });
+ });
+
+ describe('handleComponentAppear', () => {
+ it('calls `focusInput` on searchInput field', async () => {
+ wrapper.vm.$refs.searchInput.focusInput = jest.fn();
+
+ await wrapper.vm.handleComponentAppear();
+
+ expect(wrapper.vm.$refs.searchInput.focusInput).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleComponentDisappear', () => {
+ it('calls action `receiveLabelsSuccess` with empty array', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+
+ wrapper.vm.handleComponentDisappear();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ });
+ });
+
+ describe('handleCreateLabelClick', () => {
+ it('calls actions `receiveLabelsSuccess` with empty array and `toggleDropdownContentsCreateView`', () => {
+ jest.spyOn(wrapper.vm, 'receiveLabelsSuccess');
+ jest.spyOn(wrapper.vm, 'toggleDropdownContentsCreateView');
+
+ wrapper.vm.handleCreateLabelClick();
+
+ expect(wrapper.vm.receiveLabelsSuccess).toHaveBeenCalledWith([]);
+ expect(wrapper.vm.toggleDropdownContentsCreateView).toHaveBeenCalled();
+ });
+ });
+
+ describe('handleKeyDown', () => {
+ it('decreases `currentHighlightItem` value by 1 when Up arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: UP_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(0);
+ });
+
+ it('increases `currentHighlightItem` value by 1 when Down arrow key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ expect(wrapper.vm.currentHighlightItem).toBe(2);
+ });
+
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
+ it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([
+ {
+ ...mockLabels[1],
+ set: true,
+ },
+ ]);
+ });
+
+ it('calls action `toggleDropdownContents` when Esc key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ESC_KEY_CODE,
+ });
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+
+ it('calls action `scrollIntoViewIfNeeded` in next tick when any key is pressed', () => {
+ jest.spyOn(wrapper.vm, 'scrollIntoViewIfNeeded').mockImplementation();
+ wrapper.setData({
+ currentHighlightItem: 1,
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: DOWN_KEY_CODE,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.vm.scrollIntoViewIfNeeded).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('handleLabelClick', () => {
+ beforeEach(() => {
+ jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
+ });
+
+ it('calls action `updateSelectedLabels` with provided `label` param', () => {
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.updateSelectedLabels).toHaveBeenCalledWith([mockRegularLabel]);
+ });
+
+ it('calls action `toggleDropdownContents` when `state.allowMultiselect` is false', () => {
+ jest.spyOn(wrapper.vm, 'toggleDropdownContents');
+ wrapper.vm.$store.state.allowMultiselect = false;
+
+ wrapper.vm.handleLabelClick(mockRegularLabel);
+
+ expect(wrapper.vm.toggleDropdownContents).toHaveBeenCalled();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-intersection-observer as component root', () => {
+ expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true);
+ });
+
+ it('renders gl-loading-icon component when `labelsFetchInProgress` prop is true', () => {
+ wrapper.vm.$store.dispatch('requestLabels');
+
+ return wrapper.vm.$nextTick(() => {
+ const loadingIconEl = findLoadingIcon();
+
+ expect(loadingIconEl.exists()).toBe(true);
+ expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading');
+ });
+ });
+
+ it('renders label search input element', () => {
+ const searchInputEl = wrapper.find(GlSearchBoxByType);
+
+ expect(searchInputEl.exists()).toBe(true);
+ });
+
+ it('renders label elements for all labels', () => {
+ expect(wrapper.findAll(LabelItem)).toHaveLength(mockLabels.length);
+ });
+
+ it('renders label element with `highlight` set to true when value of `currentHighlightItem` is more than -1', () => {
+ wrapper.setData({
+ currentHighlightItem: 0,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const labelItemEl = findDropdownContent().find(LabelItem);
+
+ expect(labelItemEl.attributes('highlight')).toBe('true');
+ });
+ });
+
+ it('renders element containing "No matching results" when `searchKey` does not match with any label', () => {
+ wrapper.setData({
+ searchKey: 'abc',
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ const noMatchEl = findDropdownContent().find('li');
+
+ expect(noMatchEl.isVisible()).toBe(true);
+ expect(noMatchEl.text()).toContain('No matching results');
+ });
+ });
+
+ it('renders empty content while loading', () => {
+ wrapper.vm.$store.state.labelsFetchInProgress = true;
+
+ return wrapper.vm.$nextTick(() => {
+ const dropdownContent = findDropdownContent();
+ const loadingIcon = findLoadingIcon();
+
+ expect(dropdownContent.exists()).toBe(true);
+ expect(dropdownContent.isVisible()).toBe(true);
+ expect(loadingIcon.exists()).toBe(true);
+ expect(loadingIcon.isVisible()).toBe(true);
+ });
+ });
+
+ it('renders footer list items', () => {
+ const footerLinks = findDropdownFooter().findAll(GlLink);
+ const createLabelLink = footerLinks.at(0);
+ const manageLabelsLink = footerLinks.at(1);
+
+ expect(createLabelLink.exists()).toBe(true);
+ expect(createLabelLink.text()).toBe('Create label');
+ expect(manageLabelsLink.exists()).toBe(true);
+ expect(manageLabelsLink.text()).toBe('Manage labels');
+ });
+
+ it('does not render "Create label" footer link when `state.allowLabelCreate` is `false`', () => {
+ wrapper.vm.$store.state.allowLabelCreate = false;
+
+ return wrapper.vm.$nextTick(() => {
+ const createLabelLink = findDropdownFooter().findAll(GlLink).at(0);
+
+ expect(createLabelLink.text()).not.toBe('Create label');
+ });
+ });
+
+ it('does not render footer list items when `state.variant` is "standalone"', () => {
+ createComponent({ ...mockConfig, variant: 'standalone' });
+ expect(findDropdownFooter().exists()).toBe(false);
+ });
+
+ it('renders footer list items when `state.variant` is "embedded"', () => {
+ expect(findDropdownFooter().exists()).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
new file mode 100644
index 00000000000..8273bbdf7a7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js
@@ -0,0 +1,72 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+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 labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig, defaultProps = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownContents, {
+ propsData: {
+ ...defaultProps,
+ labelsCreateTitle: 'test',
+ },
+ localVue,
+ store,
+ });
+};
+
+describe('DropdownContent', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('computed', () => {
+ describe('dropdownContentsView', () => {
+ it('returns string "dropdown-contents-create-view" when `showDropdownContentsCreateView` prop is `true`', () => {
+ wrapper.vm.$store.dispatch('toggleDropdownContentsCreateView');
+
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-create-view');
+ });
+
+ it('returns string "dropdown-contents-labels-view" when `showDropdownContentsCreateView` prop is `false`', () => {
+ expect(wrapper.vm.dropdownContentsView).toBe('dropdown-contents-labels-view');
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => {
+ expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents');
+ expect(wrapper.attributes('style')).toBeUndefined();
+ });
+
+ describe('when `renderOnTop` is true', () => {
+ it.each`
+ variant | expected
+ ${DropdownVariant.Sidebar} | ${'bottom: 3rem'}
+ ${DropdownVariant.Standalone} | ${'bottom: 2rem'}
+ ${DropdownVariant.Embedded} | ${'bottom: 2rem'}
+ `('renders upward for $variant variant', ({ variant, expected }) => {
+ wrapper = createComponent({ ...mockConfig, variant }, { renderOnTop: true });
+
+ expect(wrapper.attributes('style')).toContain(expected);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
new file mode 100644
index 00000000000..d2401a1f725
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_title_spec.js
@@ -0,0 +1,61 @@
+import { GlButton, GlLoadingIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+const createComponent = (initialState = mockConfig) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', initialState);
+
+ return shallowMount(DropdownTitle, {
+ localVue,
+ store,
+ propsData: {
+ labelsSelectInProgress: false,
+ },
+ });
+};
+
+describe('DropdownTitle', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders component container element with string "Labels"', () => {
+ expect(wrapper.text()).toContain('Labels');
+ });
+
+ it('renders edit link', () => {
+ const editBtnEl = wrapper.find(GlButton);
+
+ expect(editBtnEl.exists()).toBe(true);
+ expect(editBtnEl.text()).toBe('Edit');
+ });
+
+ it('renders loading icon element when `labelsSelectInProgress` prop is true', () => {
+ wrapper.setProps({
+ labelsSelectInProgress: true,
+ });
+
+ return wrapper.vm.$nextTick(() => {
+ expect(wrapper.find(GlLoadingIcon).isVisible()).toBe(true);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
new file mode 100644
index 00000000000..59f3268c000
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js
@@ -0,0 +1,88 @@
+import { GlLabel } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig, mockRegularLabel, mockScopedLabel } from './mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('DropdownValue', () => {
+ let wrapper;
+
+ const createComponent = (initialState = {}, slots = {}) => {
+ const store = new Vuex.Store(labelsSelectModule());
+
+ store.dispatch('setInitialState', { ...mockConfig, ...initialState });
+
+ wrapper = shallowMount(DropdownValue, {
+ localVue,
+ store,
+ slots,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('methods', () => {
+ describe('labelFilterUrl', () => {
+ it('returns a label filter URL based on provided label param', () => {
+ createComponent();
+
+ expect(wrapper.vm.labelFilterUrl(mockRegularLabel)).toBe(
+ '/gitlab-org/my-project/issues?label_name[]=Foo%20Label',
+ );
+ });
+ });
+
+ describe('scopedLabel', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('returns `true` when provided label param is a scoped label', () => {
+ expect(wrapper.vm.scopedLabel(mockScopedLabel)).toBe(true);
+ });
+
+ it('returns `false` when provided label param is a regular label', () => {
+ expect(wrapper.vm.scopedLabel(mockRegularLabel)).toBe(false);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders class `has-labels` on component container element when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.attributes('class')).toContain('has-labels');
+ });
+
+ it('renders element containing `None` when `selectedLabels` is empty', () => {
+ createComponent(
+ {
+ selectedLabels: [],
+ },
+ {
+ default: 'None',
+ },
+ );
+ const noneEl = wrapper.find('span.text-secondary');
+
+ expect(noneEl.exists()).toBe(true);
+ expect(noneEl.text()).toBe('None');
+ });
+
+ it('renders labels when `selectedLabels` is not empty', () => {
+ createComponent();
+
+ expect(wrapper.findAll(GlLabel).length).toBe(2);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
new file mode 100644
index 00000000000..23810339833
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js
@@ -0,0 +1,84 @@
+import { GlIcon, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+
+import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue';
+import { mockRegularLabel } from './mock_data';
+
+const mockLabel = { ...mockRegularLabel, set: true };
+
+const createComponent = ({
+ label = mockLabel,
+ isLabelSet = mockLabel.set,
+ highlight = true,
+} = {}) =>
+ shallowMount(LabelItem, {
+ propsData: {
+ label,
+ isLabelSet,
+ highlight,
+ },
+ });
+
+describe('LabelItem', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('template', () => {
+ it('renders gl-link component', () => {
+ expect(wrapper.find(GlLink).exists()).toBe(true);
+ });
+
+ it('renders component root with class `is-focused` when `highlight` prop is true', () => {
+ const wrapperTemp = createComponent({
+ highlight: true,
+ });
+
+ expect(wrapperTemp.classes()).toContain('is-focused');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible gl-icon component when `isLabelSet` prop is true', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: true,
+ });
+
+ const iconEl = wrapperTemp.find(GlIcon);
+
+ expect(iconEl.isVisible()).toBe(true);
+ expect(iconEl.props('name')).toBe('mobile-issue-close');
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders visible span element as placeholder instead of gl-icon when `isLabelSet` prop is false', () => {
+ const wrapperTemp = createComponent({
+ isLabelSet: false,
+ });
+
+ const placeholderEl = wrapperTemp.find('[data-testid="no-icon"]');
+
+ expect(placeholderEl.isVisible()).toBe(true);
+
+ wrapperTemp.destroy();
+ });
+
+ it('renders label color element', () => {
+ const colorEl = wrapper.find('[data-testid="label-color-box"]');
+
+ expect(colorEl.exists()).toBe(true);
+ expect(colorEl.attributes('style')).toBe('background-color: rgb(186, 218, 85);');
+ });
+
+ it('renders label title', () => {
+ expect(wrapper.text()).toContain(mockLabel.title);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
new file mode 100644
index 00000000000..ee1346c362f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js
@@ -0,0 +1,241 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+
+import { isInViewport } from '~/lib/utils/common_utils';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants';
+import DropdownButton from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_button.vue';
+import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue';
+import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_title.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 LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue';
+
+import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_widget/store';
+
+import { mockConfig } from './mock_data';
+
+jest.mock('~/lib/utils/common_utils', () => ({
+ isInViewport: jest.fn().mockReturnValue(true),
+}));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('LabelsSelectRoot', () => {
+ let wrapper;
+ let store;
+
+ const createComponent = (config = mockConfig, slots = {}) => {
+ wrapper = shallowMount(LabelsSelectRoot, {
+ localVue,
+ slots,
+ store,
+ propsData: config,
+ stubs: {
+ 'dropdown-contents': DropdownContents,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ store = new Vuex.Store(labelsSelectModule());
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('methods', () => {
+ describe('handleVuexActionDispatch', () => {
+ it('calls `handleDropdownClose` when params `action.type` is `toggleDropdownContents` and state has `showDropdownButton` & `showDropdownContents` props `false`', () => {
+ createComponent();
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, touched: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ touched: true,
+ },
+ ]),
+ );
+ });
+
+ it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => {
+ createComponent({
+ ...mockConfig,
+ variant: 'embedded',
+ });
+
+ jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation();
+
+ wrapper.vm.handleVuexActionDispatch(
+ { type: 'toggleDropdownContents' },
+ {
+ showDropdownButton: false,
+ showDropdownContents: false,
+ labels: [{ id: 1 }, { id: 2, set: true }],
+ },
+ );
+
+ expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith(
+ expect.arrayContaining([
+ {
+ id: 2,
+ set: true,
+ },
+ ]),
+ );
+ });
+ });
+
+ describe('handleDropdownClose', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('emits `updateSelectedLabels` & `onDropdownClose` events on component when provided `labels` param is not empty', () => {
+ wrapper.vm.handleDropdownClose([{ id: 1 }, { id: 2 }]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeTruthy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+
+ it('emits only `onDropdownClose` event on component when provided `labels` param is empty', () => {
+ wrapper.vm.handleDropdownClose([]);
+
+ expect(wrapper.emitted().updateSelectedLabels).toBeFalsy();
+ expect(wrapper.emitted().onDropdownClose).toBeTruthy();
+ });
+ });
+
+ describe('handleCollapsedValueClick', () => {
+ it('emits `toggleCollapse` event on component', () => {
+ createComponent();
+ wrapper.vm.handleCollapsedValueClick();
+
+ expect(wrapper.emitted().toggleCollapse).toBeTruthy();
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders component with classes `labels-select-wrapper position-relative`', () => {
+ createComponent();
+ expect(wrapper.attributes('class')).toContain('labels-select-wrapper position-relative');
+ });
+
+ it.each`
+ variant | cssClass
+ ${'standalone'} | ${'is-standalone'}
+ ${'embedded'} | ${'is-embedded'}
+ `(
+ 'renders component root element with CSS class `$cssClass` when `state.variant` is "$variant"',
+ ({ variant, cssClass }) => {
+ createComponent({
+ ...mockConfig,
+ variant,
+ });
+
+ return wrapper.vm.$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);
+ });
+
+ it('renders `dropdown-title` component', async () => {
+ createComponent();
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownTitle).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-value` component', async () => {
+ createComponent(mockConfig, {
+ default: 'None',
+ });
+ await wrapper.vm.$nextTick;
+
+ const valueComp = wrapper.find(DropdownValue);
+
+ expect(valueComp.exists()).toBe(true);
+ expect(valueComp.text()).toBe('None');
+ });
+
+ it('renders `dropdown-button` component when `showDropdownButton` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownButton');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownButton).exists()).toBe(true);
+ });
+
+ it('renders `dropdown-contents` component when `showDropdownButton` & `showDropdownContents` prop is `true`', async () => {
+ createComponent();
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ await wrapper.vm.$nextTick;
+ expect(wrapper.find(DropdownContents).exists()).toBe(true);
+ });
+
+ describe('sets content direction based on viewport', () => {
+ describe.each(Object.values(DropdownVariant))(
+ 'when labels variant is "%s"',
+ ({ variant }) => {
+ beforeEach(() => {
+ createComponent({ ...mockConfig, variant });
+ wrapper.vm.$store.dispatch('toggleDropdownContents');
+ });
+
+ it('set direction when out of viewport', () => {
+ isInViewport.mockImplementation(() => false);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true);
+ });
+ });
+
+ it('does not set direction when inside of viewport', () => {
+ isInViewport.mockImplementation(() => true);
+ wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
+ });
+ });
+ },
+ );
+ });
+ });
+
+ it('calls toggleDropdownContents action when isEditing prop is changing to true', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: true });
+
+ expect(store.dispatch).toHaveBeenCalledWith('toggleDropdownContents');
+ });
+
+ it('does not call toggleDropdownContents action when isEditing prop is changing to false', async () => {
+ createComponent();
+
+ jest.spyOn(store, 'dispatch').mockResolvedValue();
+ await wrapper.setProps({ isEditing: false });
+
+ expect(store.dispatch).not.toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
new file mode 100644
index 00000000000..9e29030fb56
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js
@@ -0,0 +1,93 @@
+export const mockRegularLabel = {
+ id: 26,
+ title: 'Foo Label',
+ description: 'Foobar',
+ color: '#BADA55',
+ textColor: '#FFFFFF',
+};
+
+export const mockScopedLabel = {
+ id: 27,
+ title: 'Foo::Bar',
+ description: 'Foobar',
+ color: '#0033CC',
+ textColor: '#FFFFFF',
+};
+
+export const mockLabels = [
+ mockRegularLabel,
+ mockScopedLabel,
+ {
+ id: 28,
+ title: 'Bug',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
+];
+
+export const mockConfig = {
+ allowLabelEdit: true,
+ allowLabelCreate: true,
+ allowScopedLabels: true,
+ allowMultiselect: true,
+ labelsListTitle: 'Assign labels',
+ labelsCreateTitle: 'Create label',
+ variant: 'sidebar',
+ dropdownOnly: false,
+ selectedLabels: [mockRegularLabel, mockScopedLabel],
+ labelsSelectInProgress: false,
+ labelsFetchPath: '/gitlab-org/my-project/-/labels.json',
+ labelsManagePath: '/gitlab-org/my-project/-/labels',
+ labelsFilterBasePath: '/gitlab-org/my-project/issues',
+ labelsFilterParam: 'label_name',
+};
+
+export const mockSuggestedColors = {
+ '#009966': 'Green-cyan',
+ '#8fbc8f': 'Dark sea green',
+ '#3cb371': 'Medium sea green',
+ '#00b140': 'Green screen',
+ '#013220': 'Dark green',
+ '#6699cc': 'Blue-gray',
+ '#0000ff': 'Blue',
+ '#e6e6fa': 'Lavendar',
+ '#9400d3': 'Dark violet',
+ '#330066': 'Deep violet',
+ '#808080': 'Gray',
+ '#36454f': 'Charcoal grey',
+ '#f7e7ce': 'Champagne',
+ '#c21e56': 'Rose red',
+ '#cc338b': 'Magenta-pink',
+ '#dc143c': 'Crimson',
+ '#ff0000': 'Red',
+ '#cd5b45': 'Dark coral',
+ '#eee600': 'Titanium yellow',
+ '#ed9121': 'Carrot orange',
+ '#c39953': 'Aztec Gold',
+};
+
+export const createLabelSuccessfulResponse = {
+ data: {
+ labelCreate: {
+ label: {
+ id: 'gid://gitlab/ProjectLabel/126',
+ color: '#dc143c',
+ description: null,
+ descriptionHtml: '',
+ title: 'ewrwrwer',
+ textColor: '#FFFFFF',
+ __typename: 'Label',
+ },
+ errors: [],
+ __typename: 'LabelCreatePayload',
+ },
+ },
+};
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
new file mode 100644
index 00000000000..7ef4b769b6b
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/actions_spec.js
@@ -0,0 +1,176 @@
+import MockAdapter from 'axios-mock-adapter';
+
+import testAction from 'helpers/vuex_action_helper';
+import axios from '~/lib/utils/axios_utils';
+import * as actions from '~/vue_shared/components/sidebar/labels_select_widget/store/actions';
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import defaultState from '~/vue_shared/components/sidebar/labels_select_widget/store/state';
+
+describe('LabelsSelect Actions', () => {
+ let state;
+ const mockInitialState = {
+ labels: [],
+ selectedLabels: [],
+ };
+
+ beforeEach(() => {
+ state = { ...defaultState() };
+ });
+
+ describe('setInitialState', () => {
+ it('sets initial store state', (done) => {
+ testAction(
+ actions.setInitialState,
+ mockInitialState,
+ state,
+ [{ type: types.SET_INITIAL_STATE, payload: mockInitialState }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownButton', () => {
+ it('toggles dropdown button', (done) => {
+ testAction(
+ actions.toggleDropdownButton,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_BUTTON }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContents', () => {
+ it('toggles dropdown contents', (done) => {
+ testAction(
+ actions.toggleDropdownContents,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('toggleDropdownContentsCreateView', () => {
+ it('toggles dropdown create view', (done) => {
+ testAction(
+ actions.toggleDropdownContentsCreateView,
+ {},
+ state,
+ [{ type: types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('requestLabels', () => {
+ it('sets value of `state.labelsFetchInProgress` to `true`', (done) => {
+ testAction(actions.requestLabels, {}, state, [{ type: types.REQUEST_LABELS }], [], done);
+ });
+ });
+
+ describe('receiveLabelsSuccess', () => {
+ it('sets provided labels to `state.labels`', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.receiveLabelsSuccess,
+ labels,
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_SUCCESS, payload: labels }],
+ [],
+ done,
+ );
+ });
+ });
+
+ describe('receiveLabelsFailure', () => {
+ beforeEach(() => {
+ setFixtures('<div class="flash-container"></div>');
+ });
+
+ it('sets value `state.labelsFetchInProgress` to `false`', (done) => {
+ testAction(
+ actions.receiveLabelsFailure,
+ {},
+ state,
+ [{ type: types.RECEIVE_SET_LABELS_FAILURE }],
+ [],
+ done,
+ );
+ });
+
+ it('shows flash error', () => {
+ actions.receiveLabelsFailure({ commit: () => {} });
+
+ expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe(
+ 'Error fetching labels.',
+ );
+ });
+ });
+
+ describe('fetchLabels', () => {
+ let mock;
+
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+ state.labelsFetchPath = 'labels.json';
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('on success', () => {
+ it('dispatches `requestLabels` & `receiveLabelsSuccess` actions', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ mock.onGet(/labels.json/).replyOnce(200, labels);
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsSuccess', payload: labels }],
+ done,
+ );
+ });
+ });
+
+ describe('on failure', () => {
+ it('dispatches `requestLabels` & `receiveLabelsFailure` actions', (done) => {
+ mock.onGet(/labels.json/).replyOnce(500, {});
+
+ testAction(
+ actions.fetchLabels,
+ {},
+ state,
+ [],
+ [{ type: 'requestLabels' }, { type: 'receiveLabelsFailure' }],
+ done,
+ );
+ });
+ });
+ });
+
+ describe('updateSelectedLabels', () => {
+ it('updates `state.labels` based on provided `labels` param', (done) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ testAction(
+ actions.updateSelectedLabels,
+ labels,
+ state,
+ [{ type: types.UPDATE_SELECTED_LABELS, payload: { labels } }],
+ [],
+ done,
+ );
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
new file mode 100644
index 00000000000..40eb0323146
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/getters_spec.js
@@ -0,0 +1,59 @@
+import * as getters from '~/vue_shared/components/sidebar/labels_select_widget/store/getters';
+
+describe('LabelsSelect Getters', () => {
+ describe('dropdownButtonText', () => {
+ it.each`
+ labelType | dropdownButtonText | expected
+ ${'default'} | ${''} | ${'Label'}
+ ${'custom'} | ${'Custom label'} | ${'Custom label'}
+ `(
+ 'returns $labelType text when state.labels has no selected labels',
+ ({ dropdownButtonText, expected }) => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+ const selectedLabels = [];
+ const state = { labels, selectedLabels, dropdownButtonText };
+
+ expect(getters.dropdownButtonText(state, {})).toBe(expected);
+ },
+ );
+
+ it('returns label title when state.labels has only 1 label', () => {
+ const labels = [{ id: 1, title: 'Foobar', set: true }];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foobar',
+ );
+ });
+
+ it('returns first label title and remaining labels count when state.labels has more than 1 label', () => {
+ const labels = [
+ { id: 1, title: 'Foo', set: true },
+ { id: 2, title: 'Bar', set: true },
+ ];
+
+ expect(getters.dropdownButtonText({ labels }, { isDropdownVariantSidebar: true })).toBe(
+ 'Foo +1 more',
+ );
+ });
+ });
+
+ describe('selectedLabelsList', () => {
+ it('returns array of IDs of all labels within `state.selectedLabels`', () => {
+ const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ expect(getters.selectedLabelsList({ selectedLabels })).toEqual([1, 2, 3, 4]);
+ });
+ });
+
+ describe('isDropdownVariantSidebar', () => {
+ it('returns `true` when `state.variant` is "sidebar"', () => {
+ expect(getters.isDropdownVariantSidebar({ variant: 'sidebar' })).toBe(true);
+ });
+ });
+
+ describe('isDropdownVariantStandalone', () => {
+ it('returns `true` when `state.variant` is "standalone"', () => {
+ expect(getters.isDropdownVariantStandalone({ variant: 'standalone' })).toBe(true);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
new file mode 100644
index 00000000000..acb275b5d90
--- /dev/null
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_widget/store/mutations_spec.js
@@ -0,0 +1,140 @@
+import * as types from '~/vue_shared/components/sidebar/labels_select_widget/store/mutation_types';
+import mutations from '~/vue_shared/components/sidebar/labels_select_widget/store/mutations';
+
+describe('LabelsSelect Mutations', () => {
+ describe(`${types.SET_INITIAL_STATE}`, () => {
+ it('initializes provided props to store state', () => {
+ const state = {};
+ mutations[types.SET_INITIAL_STATE](state, {
+ labels: 'foo',
+ });
+
+ expect(state.labels).toEqual('foo');
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_BUTTON}`, () => {
+ it('toggles value of `state.showDropdownButton`', () => {
+ const state = {
+ showDropdownButton: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_BUTTON](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS}`, () => {
+ it('toggles value of `state.showDropdownButton` when `state.dropdownOnly` is false', () => {
+ const state = {
+ dropdownOnly: false,
+ showDropdownButton: false,
+ variant: 'sidebar',
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownButton).toBe(true);
+ });
+
+ it('toggles value of `state.showDropdownContents`', () => {
+ const state = {
+ showDropdownContents: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContents).toBe(true);
+ });
+
+ it('sets value of `state.showDropdownContentsCreateView` to `false` when `showDropdownContents` is true', () => {
+ const state = {
+ showDropdownContents: false,
+ showDropdownContentsCreateView: true,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(false);
+ });
+ });
+
+ describe(`${types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW}`, () => {
+ it('toggles value of `state.showDropdownContentsCreateView`', () => {
+ const state = {
+ showDropdownContentsCreateView: false,
+ };
+ mutations[types.TOGGLE_DROPDOWN_CONTENTS_CREATE_VIEW](state);
+
+ expect(state.showDropdownContentsCreateView).toBe(true);
+ });
+ });
+
+ describe(`${types.REQUEST_LABELS}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to true', () => {
+ const state = {
+ labelsFetchInProgress: false,
+ };
+ mutations[types.REQUEST_LABELS](state);
+
+ expect(state.labelsFetchInProgress).toBe(true);
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_SUCCESS}`, () => {
+ const selectedLabels = [{ id: 2 }, { id: 4 }];
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+
+ it('sets provided `labels` to `state.labels` along with `set` prop based on `state.selectedLabels`', () => {
+ const selectedLabelIds = selectedLabels.map((label) => label.id);
+ const state = {
+ selectedLabels,
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_SUCCESS](state, labels);
+
+ state.labels.forEach((label) => {
+ if (selectedLabelIds.includes(label.id)) {
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+
+ describe(`${types.RECEIVE_SET_LABELS_FAILURE}`, () => {
+ it('sets value of `state.labelsFetchInProgress` to false', () => {
+ const state = {
+ labelsFetchInProgress: true,
+ };
+ mutations[types.RECEIVE_SET_LABELS_FAILURE](state);
+
+ expect(state.labelsFetchInProgress).toBe(false);
+ });
+ });
+
+ describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
+ const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
+
+ it('updates `state.labels` to include `touched` and `set` props based on provided `labels` param', () => {
+ const updatedLabelIds = [2];
+ const state = {
+ labels,
+ };
+ mutations[types.UPDATE_SELECTED_LABELS](state, { labels: [{ id: 2 }] });
+
+ state.labels.forEach((label) => {
+ if (updatedLabelIds.includes(label.id)) {
+ expect(label.touched).toBe(true);
+ expect(label.set).toBe(true);
+ }
+ });
+ });
+ });
+});