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:
Diffstat (limited to 'spec/frontend/vue_shared')
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js (renamed from spec/frontend/vue_shared/alert_details/sidebar/alert_managment_sidebar_assignees_spec.js)83
-rw-r--r--spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js68
-rw-r--r--spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js48
-rw-r--r--spec/frontend/vue_shared/components/commit_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js87
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js38
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js4
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js228
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js3
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js78
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js37
-rw-r--r--spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/keep_alive_slots_spec.js122
-rw-r--r--spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/markdown/header_spec.js9
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/registry/list_item_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js40
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js17
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js50
-rw-r--r--spec/frontend/vue_shared/components/time_ago_tooltip_spec.js71
-rw-r--r--spec/frontend/vue_shared/components/user_select_spec.js311
-rw-r--r--spec/frontend/vue_shared/components/vuex_module_provider_spec.js47
-rw-r--r--spec/frontend/vue_shared/directives/validation_spec.js201
-rw-r--r--spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js63
-rw-r--r--spec/frontend/vue_shared/new_namespace/components/welcome_spec.js78
-rw-r--r--spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js114
-rw-r--r--spec/frontend/vue_shared/security_reports/components/apollo_mocks.js12
-rw-r--r--spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js184
-rw-r--r--spec/frontend/vue_shared/security_reports/mock_data.js120
-rw-r--r--spec/frontend/vue_shared/security_reports/security_reports_app_spec.js13
-rw-r--r--spec/frontend/vue_shared/security_reports/utils_spec.js25
35 files changed, 2065 insertions, 203 deletions
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
index 28646994ed1..db9b0930c06 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_managment_sidebar_assignees_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js
@@ -1,7 +1,7 @@
import { GlDropdownItem } from '@gitlab/ui';
-import { shallowMount } from '@vue/test-utils';
import axios from 'axios';
import MockAdapter from 'axios-mock-adapter';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
import SidebarAssignee from '~/vue_shared/alert_details/components/sidebar/sidebar_assignee.vue';
import SidebarAssignees from '~/vue_shared/alert_details/components/sidebar/sidebar_assignees.vue';
import AlertSetAssignees from '~/vue_shared/alert_details/graphql/mutations/alert_set_assignees.mutation.graphql';
@@ -13,6 +13,29 @@ describe('Alert Details Sidebar Assignees', () => {
let wrapper;
let mock;
+ const mockPath = '/-/autocomplete/users.json';
+ const mockUsers = [
+ {
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 1,
+ name: 'User 1',
+ username: 'root',
+ },
+ {
+ avatar_url:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 2,
+ name: 'User 2',
+ username: 'not-root',
+ },
+ ];
+
+ const findAssigned = () => wrapper.findByTestId('assigned-users');
+ const findDropdown = () => wrapper.findComponent(GlDropdownItem);
+ const findSidebarIcon = () => wrapper.findByTestId('assignees-icon');
+ const findUnassigned = () => wrapper.findByTestId('unassigned-users');
+
function mountComponent({
data,
users = [],
@@ -21,7 +44,7 @@ describe('Alert Details Sidebar Assignees', () => {
loading = false,
stubs = {},
} = {}) {
- wrapper = shallowMount(SidebarAssignees, {
+ wrapper = shallowMountExtended(SidebarAssignees, {
data() {
return {
users,
@@ -56,10 +79,7 @@ describe('Alert Details Sidebar Assignees', () => {
mock.restore();
});
- const findAssigned = () => wrapper.find('[data-testid="assigned-users"]');
- const findUnassigned = () => wrapper.find('[data-testid="unassigned-users"]');
-
- describe('updating the alert status', () => {
+ describe('sidebar expanded', () => {
const mockUpdatedMutationResult = {
data: {
alertSetAssignees: {
@@ -73,30 +93,13 @@ describe('Alert Details Sidebar Assignees', () => {
beforeEach(() => {
mock = new MockAdapter(axios);
- const path = '/-/autocomplete/users.json';
- const users = [
- {
- avatar_url:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- id: 1,
- name: 'User 1',
- username: 'root',
- },
- {
- avatar_url:
- 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
- id: 2,
- name: 'User 2',
- username: 'not-root',
- },
- ];
- mock.onGet(path).replyOnce(200, users);
+ mock.onGet(mockPath).replyOnce(200, mockUsers);
mountComponent({
data: { alert: mockAlert },
sidebarCollapsed: false,
loading: false,
- users,
+ users: mockUsers,
stubs: {
SidebarAssignee,
},
@@ -106,7 +109,11 @@ describe('Alert Details Sidebar Assignees', () => {
it('renders a unassigned option', async () => {
wrapper.setData({ isDropdownSearching: false });
await wrapper.vm.$nextTick();
- expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned');
+ expect(findDropdown().text()).toBe('Unassigned');
+ });
+
+ it('does not display the collapsed sidebar icon', () => {
+ expect(findSidebarIcon().exists()).toBe(false);
});
it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => {
@@ -170,4 +177,28 @@ describe('Alert Details Sidebar Assignees', () => {
expect(findAssigned().find('.dropdown-menu-user-username').text()).toBe('@root');
});
});
+
+ describe('sidebar collapsed', () => {
+ beforeEach(() => {
+ mock = new MockAdapter(axios);
+
+ mock.onGet(mockPath).replyOnce(200, mockUsers);
+
+ mountComponent({
+ data: { alert: mockAlert },
+ loading: false,
+ users: mockUsers,
+ stubs: {
+ SidebarAssignee,
+ },
+ });
+ });
+ it('does not display the status dropdown', () => {
+ expect(findDropdown().exists()).toBe(false);
+ });
+
+ it('does display the collapsed sidebar icon', () => {
+ expect(findSidebarIcon().exists()).toBe(true);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
index 0014957517f..d5be5b623b8 100644
--- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
+++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js
@@ -1,5 +1,5 @@
import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui';
-import { mount } from '@vue/test-utils';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql';
import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue';
import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue';
@@ -10,12 +10,13 @@ const mockAlert = mockAlerts[0];
describe('Alert Details Sidebar Status', () => {
let wrapper;
- const findStatusDropdown = () => wrapper.find(GlDropdown);
- const findStatusDropdownItem = () => wrapper.find(GlDropdownItem);
- const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon);
- const findStatusDropdownHeader = () => wrapper.find('[data-testid="dropdown-header"]');
+ const findStatusDropdown = () => wrapper.findComponent(GlDropdown);
+ const findStatusDropdownItem = () => wrapper.findComponent(GlDropdownItem);
+ const findStatusLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
+ const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header');
const findAlertStatus = () => wrapper.findComponent(AlertStatus);
- const findStatus = () => wrapper.find('[data-testid="status"]');
+ const findStatus = () => wrapper.findByTestId('status');
+ const findSidebarIcon = () => wrapper.findByTestId('status-icon');
function mountComponent({
data,
@@ -24,7 +25,7 @@ describe('Alert Details Sidebar Status', () => {
stubs = {},
provide = {},
} = {}) {
- wrapper = mount(AlertSidebarStatus, {
+ wrapper = mountExtended(AlertSidebarStatus, {
propsData: {
alert: { ...mockAlert },
...data,
@@ -52,7 +53,7 @@ describe('Alert Details Sidebar Status', () => {
}
});
- describe('Alert Sidebar Dropdown Status', () => {
+ describe('sidebar expanded', () => {
beforeEach(() => {
mountComponent({
data: { alert: mockAlert },
@@ -69,6 +70,10 @@ describe('Alert Details Sidebar Status', () => {
expect(findStatusDropdownHeader().exists()).toBe(true);
});
+ it('does not display the collapsed sidebar icon', () => {
+ expect(findSidebarIcon().exists()).toBe(false);
+ });
+
describe('updating the alert status', () => {
const mockUpdatedMutationResult = {
data: {
@@ -109,22 +114,47 @@ describe('Alert Details Sidebar Status', () => {
expect(findStatusLoadingIcon().exists()).toBe(false);
expect(findStatus().text()).toBe('Triggered');
});
+
+ it('renders default translated statuses', () => {
+ mountComponent({ sidebarCollapsed: false });
+ expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES);
+ expect(findStatus().text()).toBe('Triggered');
+ });
+
+ it('emits "alert-update" when the status has been updated', () => {
+ mountComponent({ sidebarCollapsed: false });
+ expect(wrapper.emitted('alert-update')).toBeUndefined();
+ findAlertStatus().vm.$emit('handle-updating');
+ expect(wrapper.emitted('alert-update')).toEqual([[]]);
+ });
+
+ it('renders translated statuses', () => {
+ const status = 'TEST';
+ const statuses = { [status]: 'Test' };
+ mountComponent({
+ data: { alert: { ...mockAlert, status } },
+ provide: { statuses },
+ sidebarCollapsed: false,
+ });
+ expect(findAlertStatus().props('statuses')).toBe(statuses);
+ expect(findStatus().text()).toBe(statuses.TEST);
+ });
});
});
- describe('Statuses', () => {
- it('renders default translated statuses', () => {
- mountComponent({});
- expect(findAlertStatus().props('statuses')).toBe(PAGE_CONFIG.OPERATIONS.STATUSES);
- expect(findStatus().text()).toBe('Triggered');
+ describe('sidebar collapsed', () => {
+ beforeEach(() => {
+ mountComponent({
+ data: { alert: mockAlert },
+ loading: false,
+ });
+ });
+ it('does not display the status dropdown', () => {
+ expect(findStatusDropdown().exists()).toBe(false);
});
- it('renders translated statuses', () => {
- const status = 'TEST';
- const statuses = { [status]: 'Test' };
- mountComponent({ data: { alert: { ...mockAlert, status } }, provide: { statuses } });
- expect(findAlertStatus().props('statuses')).toBe(statuses);
- expect(findStatus().text()).toBe(statuses.TEST);
+ it('does display the collapsed sidebar icon', () => {
+ expect(findSidebarIcon().exists()).toBe(true);
});
});
});
diff --git a/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js b/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js
new file mode 100644
index 00000000000..b73f4d6a396
--- /dev/null
+++ b/spec/frontend/vue_shared/components/alerts_deprecation_warning_spec.js
@@ -0,0 +1,48 @@
+import { GlAlert, GlLink } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import AlertDeprecationWarning from '~/vue_shared/components/alerts_deprecation_warning.vue';
+
+describe('AlertDetails', () => {
+ let wrapper;
+
+ function mountComponent(hasManagedPrometheus = false) {
+ wrapper = mount(AlertDeprecationWarning, {
+ provide: {
+ hasManagedPrometheus,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findAlert = () => wrapper.findComponent(GlAlert);
+ const findLink = () => wrapper.findComponent(GlLink);
+
+ describe('Alert details', () => {
+ describe('with no manual prometheus', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('renders nothing', () => {
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('with manual prometheus', () => {
+ beforeEach(() => {
+ mountComponent(true);
+ });
+
+ it('renders a deprecation notice', () => {
+ expect(findAlert().text()).toContain('GitLab-managed Prometheus is deprecated');
+ expect(findLink().attributes('href')).toContain(
+ 'operations/metrics/alerts.html#managed-prometheus-instances',
+ );
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js
index 66ceebed489..6a31742141b 100644
--- a/spec/frontend/vue_shared/components/commit_spec.js
+++ b/spec/frontend/vue_shared/components/commit_spec.js
@@ -32,8 +32,8 @@ describe('Commit component', () => {
createComponent({
tag: false,
commitRef: {
- name: 'master',
- ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/main',
},
commitUrl:
'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
@@ -55,8 +55,8 @@ describe('Commit component', () => {
props = {
tag: true,
commitRef: {
- name: 'master',
- ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/main',
},
commitUrl:
'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
@@ -122,8 +122,8 @@ describe('Commit component', () => {
props = {
tag: false,
commitRef: {
- name: 'master',
- ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/main',
},
commitUrl:
'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
@@ -145,8 +145,8 @@ describe('Commit component', () => {
props = {
tag: false,
commitRef: {
- name: 'master',
- ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/main',
},
commitUrl:
'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
@@ -158,7 +158,7 @@ describe('Commit component', () => {
createComponent(props);
const refEl = wrapper.find('.ref-name');
- expect(refEl.text()).toContain('master');
+ expect(refEl.text()).toContain('main');
expect(refEl.attributes('href')).toBe(props.commitRef.ref_url);
@@ -173,8 +173,8 @@ describe('Commit component', () => {
props = {
tag: false,
commitRef: {
- name: 'master',
- ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/main',
},
commitUrl:
'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
@@ -206,8 +206,8 @@ describe('Commit component', () => {
props = {
tag: false,
commitRef: {
- name: 'master',
- ref_url: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ ref_url: 'http://localhost/namespace2/gitlabhq/tree/main',
},
commitUrl:
'https://gitlab.com/gitlab-org/gitlab-foss/commit/b7836eddf62d663c665769e1b0960197fd215067',
@@ -232,8 +232,8 @@ describe('Commit component', () => {
it('should render path as href attribute', () => {
props = {
commitRef: {
- name: 'master',
- path: 'http://localhost/namespace2/gitlabhq/tree/master',
+ name: 'main',
+ path: 'http://localhost/namespace2/gitlabhq/tree/main',
},
};
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
index 9e96c154546..b2ed79cd75a 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js
@@ -1,3 +1,6 @@
+import { useLocalStorageSpy } from 'helpers/local_storage_helper';
+
+import AccessorUtilities from '~/lib/utils/accessor';
import {
stripQuotes,
uniqueTokens,
@@ -5,6 +8,8 @@ import {
processFilters,
filterToQueryObject,
urlQueryToFilter,
+ getRecentlyUsedTokenValues,
+ setTokenValueToRecentlyUsed,
} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
import {
@@ -14,6 +19,12 @@ import {
tokenValuePlain,
} from './mock_data';
+const mockStorageKey = 'recent-tokens';
+
+function setLocalStorageAvailability(isAvailable) {
+ jest.spyOn(AccessorUtilities, 'isLocalStorageAccessSafe').mockReturnValue(isAvailable);
+}
+
describe('Filtered Search Utils', () => {
describe('stripQuotes', () => {
it.each`
@@ -249,3 +260,79 @@ describe('urlQueryToFilter', () => {
expect(res).toEqual(result);
});
});
+
+describe('getRecentlyUsedTokenValues', () => {
+ useLocalStorageSpy();
+
+ beforeEach(() => {
+ localStorage.removeItem(mockStorageKey);
+ });
+
+ it('returns array containing recently used token values from provided recentTokenValuesStorageKey', () => {
+ setLocalStorageAvailability(true);
+
+ const mockExpectedArray = [{ foo: 'bar' }];
+ localStorage.setItem(mockStorageKey, JSON.stringify(mockExpectedArray));
+
+ expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual(mockExpectedArray);
+ });
+
+ it('returns empty array when provided recentTokenValuesStorageKey does not have anything in localStorage', () => {
+ setLocalStorageAvailability(true);
+
+ expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]);
+ });
+
+ it('returns empty array when when access to localStorage is not available', () => {
+ setLocalStorageAvailability(false);
+
+ expect(getRecentlyUsedTokenValues(mockStorageKey)).toEqual([]);
+ });
+});
+
+describe('setTokenValueToRecentlyUsed', () => {
+ const mockTokenValue1 = { foo: 'bar' };
+ const mockTokenValue2 = { bar: 'baz' };
+ useLocalStorageSpy();
+
+ beforeEach(() => {
+ localStorage.removeItem(mockStorageKey);
+ });
+
+ it('adds provided tokenValue to localStorage for recentTokenValuesStorageKey', () => {
+ setLocalStorageAvailability(true);
+
+ setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
+
+ expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([mockTokenValue1]);
+ });
+
+ it('adds provided tokenValue to localStorage at the top of existing values (i.e. Stack order)', () => {
+ setLocalStorageAvailability(true);
+
+ setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
+ setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue2);
+
+ expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([
+ mockTokenValue2,
+ mockTokenValue1,
+ ]);
+ });
+
+ it('ensures that provided tokenValue is not added twice', () => {
+ setLocalStorageAvailability(true);
+
+ setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
+ setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
+
+ expect(JSON.parse(localStorage.getItem(mockStorageKey))).toEqual([mockTokenValue1]);
+ });
+
+ it('does not add any value when acess to localStorage is not available', () => {
+ setLocalStorageAvailability(false);
+
+ setTokenValueToRecentlyUsed(mockStorageKey, mockTokenValue1);
+
+ expect(JSON.parse(localStorage.getItem(mockStorageKey))).toBeNull();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
index c24528ba4d2..23e4deab9c1 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js
@@ -1,12 +1,15 @@
import { GlFilteredSearchToken } from '@gitlab/ui';
import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
import Api from '~/api';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
import EpicToken from '~/vue_shared/components/filtered_search_bar/tokens/epic_token.vue';
+import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue';
+import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
export const mockAuthor1 = {
id: 1,
@@ -37,7 +40,7 @@ export const mockAuthor3 = {
export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3];
-export const mockBranches = [{ name: 'Master' }, { name: 'v1.x' }, { name: 'my-Branch' }];
+export const mockBranches = [{ name: 'Main' }, { name: 'v1.x' }, { name: 'my-Branch' }];
export const mockRegularMilestone = {
id: 1,
@@ -82,7 +85,7 @@ export const mockBranchToken = {
title: 'Source Branch',
unique: true,
token: BranchToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchBranches: Api.branches.bind(Api),
};
@@ -93,11 +96,20 @@ export const mockAuthorToken = {
unique: false,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: 'gitlab-org/gitlab-test',
fetchAuthors: Api.projectUsers.bind(Api),
};
+export const mockIterationToken = {
+ type: 'iteration',
+ icon: 'iteration',
+ title: 'Iteration',
+ unique: true,
+ token: IterationToken,
+ fetchIterations: () => Promise.resolve(),
+};
+
export const mockLabelToken = {
type: 'label_name',
icon: 'labels',
@@ -105,7 +117,7 @@ export const mockLabelToken = {
unique: false,
symbol: '~',
token: LabelToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchLabels: () => Promise.resolve(mockLabels),
};
@@ -116,7 +128,7 @@ export const mockMilestoneToken = {
unique: true,
symbol: '%',
token: MilestoneToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchMilestones: () => Promise.resolve({ data: mockMilestones }),
};
@@ -127,9 +139,9 @@ export const mockEpicToken = {
unique: true,
symbol: '&',
token: EpicToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
+ idProperty: 'iid',
fetchEpics: () => Promise.resolve({ data: mockEpics }),
- fetchSingleEpic: () => Promise.resolve({ data: mockEpics[0] }),
};
export const mockReactionEmojiToken = {
@@ -138,7 +150,7 @@ export const mockReactionEmojiToken = {
title: 'My-Reaction',
unique: true,
token: EmojiToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchEmojis: () => Promise.resolve(mockEmojis),
};
@@ -148,13 +160,21 @@ export const mockMembershipToken = {
title: 'Membership',
token: GlFilteredSearchToken,
unique: true,
- operators: [{ value: '=', description: 'is' }],
+ operators: OPERATOR_IS_ONLY,
options: [
{ value: 'exclude', title: 'Direct' },
{ value: 'only', title: 'Inherited' },
],
};
+export const mockWeightToken = {
+ type: 'weight',
+ icon: 'weight',
+ title: 'Weight',
+ unique: true,
+ token: WeightToken,
+};
+
export const mockMembershipTokenOptionsWithoutTitles = {
...mockMembershipToken,
options: [{ value: 'exclude' }, { value: 'only' }],
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 765e576914c..3b50927dcc6 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
@@ -11,8 +11,8 @@ import { deprecatedCreateFlash as createFlash } from '~/flash';
import axios from '~/lib/utils/axios_utils';
import {
- DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
+ DEFAULT_NONE_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
@@ -159,7 +159,7 @@ describe('AuthorToken', () => {
});
it('renders provided defaultAuthors as suggestions', async () => {
- const defaultAuthors = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+ const defaultAuthors = DEFAULT_NONE_ANY;
wrapper = createComponent({
active: true,
config: { ...mockAuthorToken, defaultAuthors },
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
new file mode 100644
index 00000000000..0db47f1f189
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js
@@ -0,0 +1,228 @@
+import { GlFilteredSearchToken } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import {
+ mockRegularLabel,
+ mockLabels,
+} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data';
+
+import { DEFAULT_LABELS } from '~/vue_shared/components/filtered_search_bar/constants';
+import {
+ getRecentlyUsedTokenValues,
+ setTokenValueToRecentlyUsed,
+} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils';
+import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue';
+
+import { mockLabelToken } from '../mock_data';
+
+jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils');
+
+const mockStorageKey = 'recent-tokens-label_name';
+
+const defaultStubs = {
+ Portal: true,
+ GlFilteredSearchToken: {
+ template: `
+ <div>
+ <slot name="view-token"></slot>
+ <slot name="view"></slot>
+ </div>
+ `,
+ },
+ GlFilteredSearchSuggestionList: {
+ template: '<div></div>',
+ methods: {
+ getValue: () => '=',
+ },
+ },
+};
+
+const defaultSlots = {
+ 'view-token': `
+ <div class="js-view-token">${mockRegularLabel.title}</div>
+ `,
+ view: `
+ <div class="js-view">${mockRegularLabel.title}</div>
+ `,
+};
+
+const mockProps = {
+ tokenConfig: mockLabelToken,
+ tokenValue: { data: '' },
+ tokenActive: false,
+ tokensListLoading: false,
+ tokenValues: [],
+ fnActiveTokenValue: jest.fn(),
+ defaultTokenValues: DEFAULT_LABELS,
+ recentTokenValuesStorageKey: mockStorageKey,
+ fnCurrentTokenValue: jest.fn(),
+};
+
+function createComponent({
+ props = { ...mockProps },
+ stubs = defaultStubs,
+ slots = defaultSlots,
+} = {}) {
+ return mount(BaseToken, {
+ propsData: {
+ ...props,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: jest.fn(),
+ suggestionsListClass: 'custom-class',
+ },
+ stubs,
+ slots,
+ });
+}
+
+describe('BaseToken', () => {
+ let wrapper;
+
+ beforeEach(() => {
+ wrapper = createComponent({
+ props: {
+ ...mockProps,
+ tokenValue: { data: `"${mockRegularLabel.title}"` },
+ tokenValues: mockLabels,
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('data', () => {
+ it('calls `getRecentlyUsedTokenValues` to populate `recentTokenValues` when `recentTokenValuesStorageKey` is defined', () => {
+ expect(getRecentlyUsedTokenValues).toHaveBeenCalledWith(mockStorageKey);
+ });
+ });
+
+ describe('computed', () => {
+ describe('currentTokenValue', () => {
+ it('calls `fnCurrentTokenValue` when it is provided', () => {
+ // We're disabling lint to trigger computed prop execution for this test.
+ // eslint-disable-next-line no-unused-vars
+ const { currentTokenValue } = wrapper.vm;
+
+ expect(wrapper.vm.fnCurrentTokenValue).toHaveBeenCalledWith(`"${mockRegularLabel.title}"`);
+ });
+ });
+
+ describe('activeTokenValue', () => {
+ it('calls `fnActiveTokenValue` when it is provided', async () => {
+ wrapper.setProps({
+ 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(
+ mockLabels,
+ `"${mockRegularLabel.title.toLowerCase()}"`,
+ );
+ });
+ });
+ });
+
+ describe('watch', () => {
+ describe('tokenActive', () => {
+ let wrapperWithTokenActive;
+
+ beforeEach(() => {
+ wrapperWithTokenActive = createComponent({
+ props: {
+ ...mockProps,
+ tokenActive: true,
+ tokenValue: { data: `"${mockRegularLabel.title}"` },
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapperWithTokenActive.destroy();
+ });
+
+ 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,
+ });
+
+ await wrapperWithTokenActive.vm.$nextTick();
+
+ expect(wrapperWithTokenActive.emitted('fetch-token-values')).toBeTruthy();
+ expect(wrapperWithTokenActive.emitted('fetch-token-values')).toEqual([
+ [`"${mockRegularLabel.title}"`],
+ ]);
+ });
+ });
+ });
+
+ describe('methods', () => {
+ describe('handleTokenValueSelected', () => {
+ it('calls `setTokenValueToRecentlyUsed` when `recentTokenValuesStorageKey` is defined', () => {
+ const mockTokenValue = {
+ id: 1,
+ title: 'Foo',
+ };
+
+ wrapper.vm.handleTokenValueSelected(mockTokenValue);
+
+ expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue);
+ });
+ });
+ });
+
+ describe('template', () => {
+ it('renders gl-filtered-search-token component', () => {
+ const wrapperWithNoStubs = createComponent({
+ stubs: {},
+ });
+ const glFilteredSearchToken = wrapperWithNoStubs.find(GlFilteredSearchToken);
+
+ expect(glFilteredSearchToken.exists()).toBe(true);
+ expect(glFilteredSearchToken.props('config')).toBe(mockLabelToken);
+
+ wrapperWithNoStubs.destroy();
+ });
+
+ it('renders `view-token` slot when present', () => {
+ expect(wrapper.find('.js-view-token').exists()).toBe(true);
+ });
+
+ it('renders `view` slot when present', () => {
+ expect(wrapper.find('.js-view').exists()).toBe(true);
+ });
+
+ describe('events', () => {
+ let wrapperWithNoStubs;
+
+ beforeEach(() => {
+ wrapperWithNoStubs = createComponent({
+ stubs: { Portal: true },
+ });
+ });
+
+ afterEach(() => {
+ wrapperWithNoStubs.destroy();
+ });
+
+ it('emits `fetch-token-values` event on component after a delay when component emits `input` event', async () => {
+ jest.useFakeTimers();
+
+ wrapperWithNoStubs.find(GlFilteredSearchToken).vm.$emit('input', { data: 'foo' });
+ await wrapperWithNoStubs.vm.$nextTick();
+
+ jest.runAllTimers();
+
+ expect(wrapperWithNoStubs.emitted('fetch-token-values')).toBeTruthy();
+ expect(wrapperWithNoStubs.emitted('fetch-token-values')[1]).toEqual(['foo']);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
index a20bc4986fc..331c9c2c14d 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js
@@ -10,10 +10,7 @@ import waitForPromises from 'helpers/wait_for_promises';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
-import {
- DEFAULT_LABEL_NONE,
- DEFAULT_LABEL_ANY,
-} from '~/vue_shared/components/filtered_search_bar/constants';
+import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants';
import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue';
import { mockBranches, mockBranchToken } from '../mock_data';
@@ -77,7 +74,7 @@ describe('BranchToken', () => {
describe('currentValue', () => {
it('returns lowercase string for `value.data`', () => {
- expect(wrapper.vm.currentValue).toBe('master');
+ expect(wrapper.vm.currentValue).toBe('main');
});
});
@@ -137,7 +134,7 @@ describe('BranchToken', () => {
});
describe('template', () => {
- const defaultBranches = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+ const defaultBranches = DEFAULT_NONE_ANY;
async function showSuggestions() {
const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment);
const suggestionsSegment = tokenSegments.at(2);
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
index 231f2f01428..fb48aea8e4f 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js
@@ -13,6 +13,7 @@ import axios from '~/lib/utils/axios_utils';
import {
DEFAULT_LABEL_NONE,
DEFAULT_LABEL_ANY,
+ DEFAULT_NONE_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue';
@@ -137,7 +138,7 @@ describe('EmojiToken', () => {
});
describe('template', () => {
- const defaultEmojis = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+ const defaultEmojis = DEFAULT_NONE_ANY;
beforeEach(async () => {
wrapper = createComponent({
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
index 0c3f9e1363f..addc058f658 100644
--- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js
@@ -68,21 +68,6 @@ describe('EpicToken', () => {
await wrapper.vm.$nextTick();
});
- describe('currentValue', () => {
- it.each`
- data | id
- ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} | ${mockEpics[0].iid}
- ${mockEpics[0].iid} | ${mockEpics[0].iid}
- ${'foobar'} | ${'foobar'}
- `('$data returns $id', async ({ data, id }) => {
- wrapper.setProps({ value: { data } });
-
- await wrapper.vm.$nextTick();
-
- expect(wrapper.vm.currentValue).toBe(id);
- });
- });
-
describe('activeEpic', () => {
it('returns object for currently present `value.data`', async () => {
wrapper.setProps({
@@ -140,20 +125,6 @@ describe('EpicToken', () => {
expect(wrapper.vm.loading).toBe(false);
});
});
-
- describe('fetchSingleEpic', () => {
- it('calls `config.fetchSingleEpic` with provided iid param', async () => {
- jest.spyOn(wrapper.vm.config, 'fetchSingleEpic');
-
- wrapper.vm.fetchSingleEpic(1);
-
- expect(wrapper.vm.config.fetchSingleEpic).toHaveBeenCalledWith(1);
-
- await waitForPromises();
-
- expect(wrapper.vm.epics).toEqual([mockEpics[0]]);
- });
- });
});
describe('template', () => {
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
new file mode 100644
index 00000000000..ca5dc984ae0
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/iteration_token_spec.js
@@ -0,0 +1,78 @@
+import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import createFlash from '~/flash';
+import IterationToken from '~/vue_shared/components/filtered_search_bar/tokens/iteration_token.vue';
+import { mockIterationToken } from '../mock_data';
+
+jest.mock('~/flash');
+
+describe('IterationToken', () => {
+ const title = 'gitlab-org: #1';
+ let wrapper;
+
+ const createComponent = ({ config = mockIterationToken, value = { data: '' } } = {}) =>
+ mount(IterationToken, {
+ propsData: {
+ config,
+ value,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders iteration value', async () => {
+ wrapper = createComponent({ value: { data: title } });
+
+ await wrapper.vm.$nextTick();
+
+ const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3); // `Iteration` `=` `gitlab-org: #1`
+ expect(tokenSegments.at(2).text()).toBe(title);
+ });
+
+ it('fetches initial values', () => {
+ const fetchIterationsSpy = jest.fn().mockResolvedValue();
+
+ wrapper = createComponent({
+ config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
+ value: { data: title },
+ });
+
+ expect(fetchIterationsSpy).toHaveBeenCalledWith(title);
+ });
+
+ it('fetches iterations on user input', () => {
+ const search = 'hello';
+ const fetchIterationsSpy = jest.fn().mockResolvedValue();
+
+ wrapper = createComponent({
+ config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
+ });
+
+ wrapper.findComponent(GlFilteredSearchToken).vm.$emit('input', { data: search });
+
+ expect(fetchIterationsSpy).toHaveBeenCalledWith(search);
+ });
+
+ it('renders error message when request fails', async () => {
+ const fetchIterationsSpy = jest.fn().mockRejectedValue();
+
+ wrapper = createComponent({
+ config: { ...mockIterationToken, fetchIterations: fetchIterationsSpy },
+ });
+
+ await wrapper.vm.$nextTick();
+
+ expect(createFlash).toHaveBeenCalledWith({
+ message: 'There was a problem fetching iterations.',
+ });
+ });
+});
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 8528c062426..57514a0c499 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
@@ -16,8 +16,7 @@ import axios from '~/lib/utils/axios_utils';
import {
DEFAULT_LABELS,
- DEFAULT_LABEL_NONE,
- DEFAULT_LABEL_ANY,
+ DEFAULT_NONE_ANY,
} from '~/vue_shared/components/filtered_search_bar/constants';
import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue';
@@ -176,7 +175,7 @@ describe('LabelToken', () => {
});
describe('template', () => {
- const defaultLabels = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY];
+ const defaultLabels = DEFAULT_NONE_ANY;
beforeEach(async () => {
wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } });
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
new file mode 100644
index 00000000000..9a72be636cd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/weight_token_spec.js
@@ -0,0 +1,37 @@
+import { GlFilteredSearchTokenSegment } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import WeightToken from '~/vue_shared/components/filtered_search_bar/tokens/weight_token.vue';
+import { mockWeightToken } from '../mock_data';
+
+jest.mock('~/flash');
+
+describe('WeightToken', () => {
+ const weight = '3';
+ let wrapper;
+
+ const createComponent = ({ config = mockWeightToken, value = { data: '' } } = {}) =>
+ mount(WeightToken, {
+ propsData: {
+ config,
+ value,
+ },
+ provide: {
+ portalName: 'fake target',
+ alignSuggestions: function fakeAlignSuggestions() {},
+ suggestionsListClass: 'custom-class',
+ },
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders weight value', () => {
+ wrapper = createComponent({ value: { data: weight } });
+
+ const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment);
+
+ expect(tokenSegments).toHaveLength(3); // `Weight` `=` `3`
+ expect(tokenSegments.at(2).text()).toBe(weight);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
index 99bf0d84d0c..8738924f717 100644
--- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
+++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js
@@ -132,6 +132,35 @@ describe('RelatedIssuableItem', () => {
it('renders due date component with correct due date', () => {
expect(wrapper.find(IssueDueDate).props('date')).toBe(props.dueDate);
});
+
+ it('does not render red icon for overdue issue that is closed', async () => {
+ mountComponent({
+ props: {
+ ...props,
+ closedAt: '2018-12-01T00:00:00.00Z',
+ },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(IssueDueDate).props('closed')).toBe(true);
+ });
+
+ it('should not contain the `.text-danger` css class for overdue issue that is closed', async () => {
+ mountComponent({
+ props: {
+ ...props,
+ closedAt: '2018-12-01T00:00:00.00Z',
+ },
+ });
+ await wrapper.vm.$nextTick();
+
+ expect(wrapper.find(IssueDueDate).find('.board-card-info-icon').classes('text-danger')).toBe(
+ false,
+ );
+ expect(wrapper.find(IssueDueDate).find('.board-card-info-text').classes('text-danger')).toBe(
+ false,
+ );
+ });
});
describe('token assignees', () => {
diff --git a/spec/frontend/vue_shared/components/keep_alive_slots_spec.js b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
new file mode 100644
index 00000000000..10c6cbe6d94
--- /dev/null
+++ b/spec/frontend/vue_shared/components/keep_alive_slots_spec.js
@@ -0,0 +1,122 @@
+import { nextTick } from 'vue';
+import { mountExtended } from 'helpers/vue_test_utils_helper';
+import KeepAliveSlots from '~/vue_shared/components/keep_alive_slots.vue';
+
+const SLOT_1 = {
+ slotKey: 'slot-1',
+ title: 'Hello 1',
+};
+const SLOT_2 = {
+ slotKey: 'slot-2',
+ title: 'Hello 2',
+};
+
+describe('~/vue_shared/components/keep_alive_slots.vue', () => {
+ let wrapper;
+
+ const createSlotContent = ({ slotKey, title }) => `
+ <div data-testid="slot-child" data-slot-id="${slotKey}">
+ <h1>${title}</h1>
+ <input type="text" />
+ </div>
+ `;
+ const createComponent = (props = {}) => {
+ wrapper = mountExtended(KeepAliveSlots, {
+ propsData: props,
+ slots: {
+ [SLOT_1.slotKey]: createSlotContent(SLOT_1),
+ [SLOT_2.slotKey]: createSlotContent(SLOT_2),
+ },
+ });
+ };
+
+ const findRenderedSlots = () =>
+ wrapper.findAllByTestId('slot-child').wrappers.map((x) => ({
+ title: x.find('h1').text(),
+ inputValue: x.find('input').element.value,
+ isVisible: x.isVisible(),
+ }));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('default', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('doesnt show anything', () => {
+ expect(findRenderedSlots()).toEqual([]);
+ });
+
+ describe('when slotKey is changed', () => {
+ beforeEach(async () => {
+ wrapper.setProps({ slotKey: SLOT_1.slotKey });
+ await nextTick();
+ });
+
+ it('shows slot', () => {
+ expect(findRenderedSlots()).toEqual([
+ {
+ title: SLOT_1.title,
+ isVisible: true,
+ inputValue: '',
+ },
+ ]);
+ });
+
+ it('hides everything when slotKey cannot be found', async () => {
+ wrapper.setProps({ slotKey: '' });
+ await nextTick();
+
+ expect(findRenderedSlots()).toEqual([
+ {
+ title: SLOT_1.title,
+ isVisible: false,
+ inputValue: '',
+ },
+ ]);
+ });
+
+ describe('when user intreracts then slotKey changes again', () => {
+ beforeEach(async () => {
+ wrapper.find('input').setValue('TEST');
+ wrapper.setProps({ slotKey: SLOT_2.slotKey });
+ await nextTick();
+ });
+
+ it('keeps first slot alive but hidden', () => {
+ expect(findRenderedSlots()).toEqual([
+ {
+ title: SLOT_1.title,
+ isVisible: false,
+ inputValue: 'TEST',
+ },
+ {
+ title: SLOT_2.title,
+ isVisible: true,
+ inputValue: '',
+ },
+ ]);
+ });
+ });
+ });
+ });
+
+ describe('initialized with slotKey', () => {
+ beforeEach(() => {
+ createComponent({ slotKey: SLOT_2.slotKey });
+ });
+
+ it('shows slot', () => {
+ expect(findRenderedSlots()).toEqual([
+ {
+ title: SLOT_2.title,
+ isVisible: true,
+ inputValue: '',
+ },
+ ]);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
index c454166e30b..3b49536799c 100644
--- a/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
+++ b/spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap
@@ -6,7 +6,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
>
<suggestion-diff-header-stub
batchsuggestionscount="1"
- class="qa-suggestion-diff-header js-suggestion-diff-header"
+ class="js-suggestion-diff-header"
defaultcommitmessage="Apply suggestion"
helppagepath="path_to_docs"
isapplyingbatch="true"
diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js
index 077c2174571..fec6abc9639 100644
--- a/spec/frontend/vue_shared/components/markdown/header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/header_spec.js
@@ -48,6 +48,7 @@ describe('Markdown field header component', () => {
'Add a bullet list',
'Add a numbered list',
'Add a task list',
+ 'Add a collapsible section',
'Add a table',
'Go full screen',
];
@@ -133,6 +134,14 @@ describe('Markdown field header component', () => {
);
});
+ it('renders collapsible section template', () => {
+ const detailsBlockButton = findToolbarButtonByProp('icon', 'details-block');
+
+ expect(detailsBlockButton.props('tag')).toEqual(
+ '<details><summary>Click to expand</summary>\n{text}\n</details>',
+ );
+ });
+
it('does not render suggestion button if `canSuggest` is set to false', () => {
createWrapper({
canSuggest: false,
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
index 74e9cbcbb53..acf97713885 100644
--- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -1,6 +1,7 @@
import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui';
import { mount } from '@vue/test-utils';
import Tracking from '~/tracking';
+import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants';
import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue';
import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue';
import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
@@ -291,7 +292,7 @@ describe('AlertManagementEmptyState', () => {
unique: true,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: '/link',
fetchAuthors: expect.any(Function),
},
@@ -302,7 +303,7 @@ describe('AlertManagementEmptyState', () => {
unique: true,
symbol: '@',
token: AuthorToken,
- operators: [{ value: '=', description: 'is', default: 'true' }],
+ operators: OPERATOR_IS_ONLY,
fetchPath: '/link',
fetchAuthors: expect.any(Function),
},
diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js
index 33c9c808dc3..ca4bf0b0652 100644
--- a/spec/frontend/vue_shared/components/registry/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js
@@ -101,16 +101,16 @@ describe('list item', () => {
});
describe('disabled prop', () => {
- it('when true applies disabled-content class', () => {
+ it('when true applies gl-opacity-5 class', () => {
mountComponent({ disabled: true });
- expect(wrapper.classes('disabled-content')).toBe(true);
+ expect(wrapper.classes('gl-opacity-5')).toBe(true);
});
- it('when false does not apply disabled-content class', () => {
+ it('when false does not apply gl-opacity-5 class', () => {
mountComponent({ disabled: false });
- expect(wrapper.classes('disabled-content')).toBe(false);
+ expect(wrapper.classes('gl-opacity-5')).toBe(false);
});
});
diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
index 4033c943b82..32ef2d27ba7 100644
--- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
+++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js
@@ -1,4 +1,5 @@
-import { GlAlert, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
+import { GlAlert, GlButton, GlLoadingIcon, GlSkeletonLoader } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { nextTick } from 'vue';
import VueApollo from 'vue-apollo';
@@ -18,6 +19,24 @@ import {
const localVue = createLocalVue();
localVue.use(VueApollo);
+let resizeCallback;
+const MockResizeObserver = {
+ bind(el, { value }) {
+ resizeCallback = value;
+ },
+ mockResize(size) {
+ bp.getBreakpointSize.mockReturnValue(size);
+ resizeCallback();
+ },
+ unbind() {
+ resizeCallback = null;
+ },
+};
+
+localVue.directive('gl-resize-observer', MockResizeObserver);
+
+jest.mock('@gitlab/ui/dist/utils');
+
describe('RunnerInstructionsModal component', () => {
let wrapper;
let fakeApollo;
@@ -27,7 +46,8 @@ describe('RunnerInstructionsModal component', () => {
const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
const findGlLoadingIcon = () => wrapper.findComponent(GlLoadingIcon);
const findAlert = () => wrapper.findComponent(GlAlert);
- const findPlatformButtons = () => wrapper.findAllByTestId('platform-button');
+ const findPlatformButtonGroup = () => wrapper.findByTestId('platform-buttons');
+ const findPlatformButtons = () => findPlatformButtonGroup().findAllComponents(GlButton);
const findArchitectureDropdownItems = () => wrapper.findAllByTestId('architecture-dropdown-item');
const findBinaryInstructions = () => wrapper.findByTestId('binary-instructions');
const findRegisterCommand = () => wrapper.findByTestId('register-command');
@@ -141,6 +161,22 @@ describe('RunnerInstructionsModal component', () => {
});
});
+ describe('when the modal resizes', () => {
+ it('to an xs viewport', async () => {
+ MockResizeObserver.mockResize('xs');
+ await nextTick();
+
+ expect(findPlatformButtonGroup().attributes('vertical')).toBeTruthy();
+ });
+
+ it('to a non-xs viewport', async () => {
+ MockResizeObserver.mockResize('sm');
+ await nextTick();
+
+ expect(findPlatformButtonGroup().props('vertical')).toBeFalsy();
+ });
+ });
+
describe('when apollo is loading', () => {
it('should show a skeleton loader', async () => {
createComponent();
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
index 1175d183c6c..88557917cb5 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js
@@ -1,8 +1,8 @@
import { shallowMount, createLocalVue } from '@vue/test-utils';
import Vuex from 'vuex';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
-
import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store';
import { mockConfig } from './mock_data';
@@ -50,13 +50,20 @@ describe('DropdownContent', () => {
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')).toBe(undefined);
+ expect(wrapper.attributes('style')).toBeUndefined();
});
- it('renders component container element with styles when `renderOnTop` is true', () => {
- wrapper = createComponent(mockConfig, { renderOnTop: true });
+ 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('bottom: 100%');
+ expect(wrapper.attributes('style')).toContain(expected);
+ });
});
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
index 4cf36df2502..3f00eab17b7 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js
@@ -3,6 +3,7 @@ import Vuex from 'vuex';
import { isInViewport } from '~/lib/utils/common_utils';
import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue';
+import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants';
import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue';
import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue';
import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue';
@@ -190,40 +191,33 @@ describe('LabelsSelectRoot', () => {
});
describe('sets content direction based on viewport', () => {
- it('does not set direction when `state.variant` is not "embedded"', async () => {
- createComponent();
-
- wrapper.vm.$store.dispatch('toggleDropdownContents');
- wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state);
- await wrapper.vm.$nextTick;
-
- expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
- });
-
- describe('when `state.variant` is "embedded"', () => {
- beforeEach(() => {
- createComponent({ ...mockConfig, variant: 'embedded' });
- wrapper.vm.$store.dispatch('toggleDropdownContents');
- });
+ 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);
+ 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);
+ 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);
+ 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);
+ return wrapper.vm.$nextTick().then(() => {
+ expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false);
+ });
});
- });
- });
+ },
+ );
});
});
diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
index 691e19473c1..28c5acc8110 100644
--- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
+++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js
@@ -1,28 +1,36 @@
import { shallowMount } from '@vue/test-utils';
+import timezoneMock from 'timezone-mock';
import { formatDate, getTimeago } from '~/lib/utils/datetime_utility';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
describe('Time ago with tooltip component', () => {
let vm;
- const buildVm = (propsData = {}, scopedSlots = {}) => {
+ const timestamp = '2017-05-08T14:57:39.781Z';
+ const timeAgoTimestamp = getTimeago().format(timestamp);
+
+ const defaultProps = {
+ time: timestamp,
+ };
+
+ const buildVm = (props = {}, scopedSlots = {}) => {
vm = shallowMount(TimeAgoTooltip, {
- propsData,
+ propsData: {
+ ...defaultProps,
+ ...props,
+ },
scopedSlots,
});
};
- const timestamp = '2017-05-08T14:57:39.781Z';
- const timeAgoTimestamp = getTimeago().format(timestamp);
afterEach(() => {
vm.destroy();
+ timezoneMock.unregister();
});
it('should render timeago with a bootstrap tooltip', () => {
- buildVm({
- time: timestamp,
- });
+ buildVm();
expect(vm.attributes('title')).toEqual(formatDate(timestamp));
expect(vm.text()).toEqual(timeAgoTimestamp);
@@ -30,7 +38,6 @@ describe('Time ago with tooltip component', () => {
it('should render provided html class', () => {
buildVm({
- time: timestamp,
cssClass: 'foo',
});
@@ -38,14 +45,58 @@ describe('Time ago with tooltip component', () => {
});
it('should render with the datetime attribute', () => {
- buildVm({ time: timestamp });
+ buildVm();
expect(vm.attributes('datetime')).toEqual(timestamp);
});
it('should render provided scope content with the correct timeAgo string', () => {
- buildVm({ time: timestamp }, { default: `<span>The time is {{ props.timeAgo }}</span>` });
+ buildVm(null, { default: `<span>The time is {{ props.timeAgo }}</span>` });
expect(vm.text()).toEqual(`The time is ${timeAgoTimestamp}`);
});
+
+ describe('number based timestamps', () => {
+ // Store a date object before we mock the TZ
+ const date = new Date();
+
+ describe('with default TZ', () => {
+ beforeEach(() => {
+ buildVm({ time: date.getTime() });
+ });
+
+ it('handled correctly', () => {
+ expect(vm.text()).toEqual(getTimeago().format(date.getTime()));
+ });
+ });
+
+ describe.each`
+ timezone | offset
+ ${'US/Pacific'} | ${420}
+ ${'US/Eastern'} | ${240}
+ ${'Brazil/East'} | ${180}
+ ${'UTC'} | ${-0}
+ ${'Europe/London'} | ${-60}
+ `('with different client vs server TZ', ({ timezone, offset }) => {
+ let tzDate;
+
+ beforeEach(() => {
+ timezoneMock.register(timezone);
+ // Date object with mocked TZ
+ tzDate = new Date();
+ buildVm({ time: date.getTime() });
+ });
+
+ it('the date object should have correct timezones', () => {
+ expect(tzDate.getTimezoneOffset()).toBe(offset);
+ });
+
+ it('timeago should handled the date correctly', () => {
+ // getTime() should always handle the TZ, which allows for us to validate the date objects represent
+ // the same date and time regardless of the TZ.
+ expect(vm.text()).toEqual(getTimeago().format(date.getTime()));
+ expect(vm.text()).toEqual(getTimeago().format(tzDate.getTime()));
+ });
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js
new file mode 100644
index 00000000000..5a609568220
--- /dev/null
+++ b/spec/frontend/vue_shared/components/user_select_spec.js
@@ -0,0 +1,311 @@
+import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { cloneDeep } from 'lodash';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import searchUsersQuery from '~/graphql_shared/queries/users_search.query.graphql';
+import { ASSIGNEES_DEBOUNCE_DELAY } from '~/sidebar/constants';
+import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql';
+import UserSelect from '~/vue_shared/components/user_select/user_select.vue';
+import {
+ searchResponse,
+ projectMembersResponse,
+ participantsQueryResponse,
+} from '../../sidebar/mock_data';
+
+const assignee = {
+ id: 'gid://gitlab/User/4',
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon',
+ name: 'Developer',
+ username: 'dev',
+ webUrl: '/dev',
+ status: null,
+};
+
+const mockError = jest.fn().mockRejectedValue('Error!');
+
+const waitForSearch = async () => {
+ jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
+ await nextTick();
+ await waitForPromises();
+};
+
+const localVue = createLocalVue();
+localVue.use(VueApollo);
+
+describe('User select dropdown', () => {
+ let wrapper;
+ let fakeApollo;
+
+ const findSearchField = () => wrapper.findComponent(GlSearchBoxByType);
+ const findParticipantsLoading = () => wrapper.find('[data-testid="loading-participants"]');
+ const findSelectedParticipants = () => wrapper.findAll('[data-testid="selected-participant"]');
+ const findUnselectedParticipants = () =>
+ wrapper.findAll('[data-testid="unselected-participant"]');
+ const findCurrentUser = () => wrapper.findAll('[data-testid="current-user"]');
+ const findUnassignLink = () => wrapper.find('[data-testid="unassign"]');
+ const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]');
+
+ const createComponent = ({
+ props = {},
+ searchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse),
+ participantsQueryHandler = jest.fn().mockResolvedValue(participantsQueryResponse),
+ } = {}) => {
+ fakeApollo = createMockApollo([
+ [searchUsersQuery, searchQueryHandler],
+ [getIssueParticipantsQuery, participantsQueryHandler],
+ ]);
+ wrapper = shallowMount(UserSelect, {
+ localVue,
+ apolloProvider: fakeApollo,
+ propsData: {
+ headerText: 'test',
+ text: 'test-text',
+ fullPath: '/project',
+ iid: '1',
+ value: [],
+ currentUser: {
+ username: 'random',
+ name: 'Mr. Random',
+ },
+ allowMultipleAssignees: false,
+ ...props,
+ },
+ stubs: {
+ GlDropdown,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ fakeApollo = null;
+ });
+
+ it('renders a loading spinner if participants are loading', () => {
+ createComponent();
+
+ expect(findParticipantsLoading().exists()).toBe(true);
+ });
+
+ it('emits an `error` event if participants query was rejected', async () => {
+ createComponent({ participantsQueryHandler: mockError });
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[], []]);
+ });
+
+ it('emits an `error` event if search query was rejected', async () => {
+ createComponent({ searchQueryHandler: mockError });
+ await waitForSearch();
+
+ expect(wrapper.emitted('error')).toEqual([[], []]);
+ });
+
+ it('renders current user if they are not in participants or assignees', async () => {
+ createComponent();
+ await waitForPromises();
+
+ expect(findCurrentUser().exists()).toBe(true);
+ });
+
+ it('displays correct amount of selected users', async () => {
+ createComponent({
+ props: {
+ value: [assignee],
+ },
+ });
+ await waitForPromises();
+
+ expect(findSelectedParticipants()).toHaveLength(1);
+ });
+
+ describe('when search is empty', () => {
+ it('renders a merged list of participants and project members', async () => {
+ createComponent();
+ await waitForPromises();
+ expect(findUnselectedParticipants()).toHaveLength(3);
+ });
+
+ it('renders `Unassigned` link with the checkmark when there are no selected users', async () => {
+ createComponent();
+ await waitForPromises();
+ expect(findUnassignLink().props('isChecked')).toBe(true);
+ });
+
+ it('renders `Unassigned` link without the checkmark when there are selected users', async () => {
+ createComponent({
+ props: {
+ value: [assignee],
+ },
+ });
+ await waitForPromises();
+ expect(findUnassignLink().props('isChecked')).toBe(false);
+ });
+
+ it('emits an input event with empty array after clicking on `Unassigned`', async () => {
+ createComponent({
+ props: {
+ value: [assignee],
+ },
+ });
+ await waitForPromises();
+ findUnassignLink().vm.$emit('click');
+
+ expect(wrapper.emitted('input')).toEqual([[[]]]);
+ });
+
+ it('emits an empty array after unselecting the only selected assignee', async () => {
+ createComponent({
+ props: {
+ value: [assignee],
+ },
+ });
+ await waitForPromises();
+
+ findSelectedParticipants().at(0).vm.$emit('click', new Event('click'));
+ expect(wrapper.emitted('input')).toEqual([[[]]]);
+ });
+
+ it('allows only one user to be selected if `allowMultipleAssignees` is false', async () => {
+ createComponent({
+ props: {
+ value: [assignee],
+ },
+ });
+ await waitForPromises();
+
+ findUnselectedParticipants().at(0).vm.$emit('click');
+ expect(wrapper.emitted('input')).toEqual([
+ [
+ [
+ {
+ avatarUrl:
+ 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon',
+ id: 'gid://gitlab/User/1',
+ name: 'Administrator',
+ status: null,
+ username: 'root',
+ webUrl: '/root',
+ },
+ ],
+ ],
+ ]);
+ });
+
+ it('adds user to selected if `allowMultipleAssignees` is true', async () => {
+ createComponent({
+ props: {
+ value: [assignee],
+ allowMultipleAssignees: true,
+ },
+ });
+ await waitForPromises();
+
+ findUnselectedParticipants().at(0).vm.$emit('click');
+ expect(wrapper.emitted('input')[0][0]).toHaveLength(2);
+ });
+ });
+
+ describe('when searching', () => {
+ it('does not show loading spinner when debounce timer is still running', async () => {
+ createComponent();
+ await waitForPromises();
+ findSearchField().vm.$emit('input', 'roo');
+
+ expect(findParticipantsLoading().exists()).toBe(false);
+ });
+
+ it('shows loading spinner when searching for users', async () => {
+ createComponent();
+ await waitForPromises();
+ findSearchField().vm.$emit('input', 'roo');
+ jest.advanceTimersByTime(ASSIGNEES_DEBOUNCE_DELAY);
+ await nextTick();
+
+ expect(findParticipantsLoading().exists()).toBe(true);
+ });
+
+ it('renders a list of found users and external participants matching search term', async () => {
+ createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) });
+ await waitForPromises();
+
+ findSearchField().vm.$emit('input', 'ro');
+ await waitForSearch();
+
+ expect(findUnselectedParticipants()).toHaveLength(3);
+ });
+
+ it('renders a list of found users only if no external participants match search term', async () => {
+ createComponent({ searchQueryHandler: jest.fn().mockResolvedValue(searchResponse) });
+ await waitForPromises();
+
+ findSearchField().vm.$emit('input', 'roo');
+ await waitForSearch();
+
+ expect(findUnselectedParticipants()).toHaveLength(2);
+ });
+
+ it('shows a message about no matches if search returned an empty list', async () => {
+ const responseCopy = cloneDeep(searchResponse);
+ responseCopy.data.workspace.users.nodes = [];
+
+ createComponent({
+ searchQueryHandler: jest.fn().mockResolvedValue(responseCopy),
+ });
+ await waitForPromises();
+ findSearchField().vm.$emit('input', 'tango');
+ await waitForSearch();
+
+ expect(findUnselectedParticipants()).toHaveLength(0);
+ expect(findEmptySearchResults().exists()).toBe(true);
+ });
+ });
+
+ // TODO Remove this test after the following issue is resolved in the backend
+ // https://gitlab.com/gitlab-org/gitlab/-/issues/329750
+ describe('temporary error suppression', () => {
+ beforeEach(() => {
+ jest.spyOn(console, 'error').mockImplementation();
+ });
+
+ const nullError = { message: 'Cannot return null for non-nullable field GroupMember.user' };
+
+ it.each`
+ mockErrors
+ ${[nullError]}
+ ${[nullError, nullError]}
+ `('does not emit errors', async ({ mockErrors }) => {
+ createComponent({
+ searchQueryHandler: jest.fn().mockResolvedValue({
+ errors: mockErrors,
+ }),
+ });
+ await waitForSearch();
+
+ expect(wrapper.emitted()).toEqual({});
+ // eslint-disable-next-line no-console
+ expect(console.error).toHaveBeenCalled();
+ });
+
+ it.each`
+ mockErrors
+ ${[{ message: 'serious error' }]}
+ ${[nullError, { message: 'serious error' }]}
+ `('emits error when non-null related errors are included', async ({ mockErrors }) => {
+ createComponent({
+ searchQueryHandler: jest.fn().mockResolvedValue({
+ errors: mockErrors,
+ }),
+ });
+ await waitForSearch();
+
+ expect(wrapper.emitted('error')).toEqual([[]]);
+ // eslint-disable-next-line no-console
+ expect(console.error).not.toHaveBeenCalled();
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/vuex_module_provider_spec.js b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
new file mode 100644
index 00000000000..ebd396bd87c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/vuex_module_provider_spec.js
@@ -0,0 +1,47 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import VuexModuleProvider from '~/vue_shared/components/vuex_module_provider.vue';
+
+const TestComponent = Vue.extend({
+ inject: ['vuexModule'],
+ template: `<div data-testid="vuexModule">{{ vuexModule }}</div> `,
+});
+
+const TEST_VUEX_MODULE = 'testVuexModule';
+
+describe('~/vue_shared/components/vuex_module_provider', () => {
+ let wrapper;
+
+ const findProvidedVuexModule = () => wrapper.find('[data-testid="vuexModule"]').text();
+
+ const createComponent = (extraParams = {}) => {
+ wrapper = mount(VuexModuleProvider, {
+ propsData: {
+ vuexModule: TEST_VUEX_MODULE,
+ },
+ slots: {
+ default: TestComponent,
+ },
+ ...extraParams,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('provides "vuexModule" set from prop', () => {
+ createComponent();
+ expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
+ });
+
+ it('does not blow up when used with vue-apollo', () => {
+ // See https://github.com/vuejs/vue-apollo/pull/1153 for details
+ const localVue = createLocalVue();
+ localVue.use(VueApollo);
+
+ createComponent({ localVue });
+ expect(findProvidedVuexModule()).toBe(TEST_VUEX_MODULE);
+ });
+});
diff --git a/spec/frontend/vue_shared/directives/validation_spec.js b/spec/frontend/vue_shared/directives/validation_spec.js
index 2764a71d204..51ee73cabde 100644
--- a/spec/frontend/vue_shared/directives/validation_spec.js
+++ b/spec/frontend/vue_shared/directives/validation_spec.js
@@ -1,15 +1,21 @@
import { shallowMount } from '@vue/test-utils';
-import validation from '~/vue_shared/directives/validation';
+import validation, { initForm } from '~/vue_shared/directives/validation';
describe('validation directive', () => {
let wrapper;
- const createComponent = ({ inputAttributes, showValidation } = {}) => {
+ const createComponentFactory = ({ inputAttributes, template, data }) => {
const defaultInputAttributes = {
type: 'text',
required: true,
};
+ const defaultTemplate = `
+ <form>
+ <input v-validation:[showValidation] name="exampleField" v-bind="attributes" />
+ </form>
+ `;
+
const component = {
directives: {
validation: validation(),
@@ -17,27 +23,52 @@ describe('validation directive', () => {
data() {
return {
attributes: inputAttributes || defaultInputAttributes,
- showValidation,
- form: {
- state: null,
- fields: {
- exampleField: {
- state: null,
- feedback: '',
- },
+ ...data,
+ };
+ },
+ template: template || defaultTemplate,
+ };
+
+ wrapper = shallowMount(component, { attachTo: document.body });
+ };
+
+ const createComponent = ({ inputAttributes, showValidation, template } = {}) =>
+ createComponentFactory({
+ inputAttributes,
+ data: {
+ showValidation,
+ form: {
+ state: null,
+ fields: {
+ exampleField: {
+ state: null,
+ feedback: '',
},
},
- };
+ },
+ },
+ template,
+ });
+
+ const createComponentWithInitForm = ({ inputAttributes } = {}) =>
+ createComponentFactory({
+ inputAttributes,
+ data: {
+ form: initForm({
+ fields: {
+ exampleField: {
+ state: null,
+ value: 'lorem',
+ },
+ },
+ }),
},
template: `
<form>
- <input v-validation:[showValidation] name="exampleField" v-bind="attributes" />
+ <input v-validation:[form.showValidation] name="exampleField" v-bind="attributes" />
</form>
`,
- };
-
- wrapper = shallowMount(component, { attachTo: document.body });
- };
+ });
afterEach(() => {
wrapper.destroy();
@@ -48,6 +79,12 @@ describe('validation directive', () => {
const findForm = () => wrapper.find('form');
const findInput = () => wrapper.find('input');
+ const setValueAndTriggerValidation = (value) => {
+ const input = findInput();
+ input.setValue(value);
+ input.trigger('blur');
+ };
+
describe.each([true, false])(
'with fields untouched and "showValidation" set to "%s"',
(showValidation) => {
@@ -78,12 +115,6 @@ describe('validation directive', () => {
`(
'with input-attributes set to $inputAttributes',
({ inputAttributes, validValue, invalidValue }) => {
- const setValueAndTriggerValidation = (value) => {
- const input = findInput();
- input.setValue(value);
- input.trigger('blur');
- };
-
beforeEach(() => {
createComponent({ inputAttributes });
});
@@ -129,4 +160,130 @@ describe('validation directive', () => {
});
},
);
+
+ describe('with group elements', () => {
+ const template = `
+ <form>
+ <div v-validation:[showValidation]>
+ <input name="exampleField" v-bind="attributes" />
+ </div>
+ </form>
+ `;
+ beforeEach(() => {
+ createComponent({
+ template,
+ inputAttributes: {
+ required: true,
+ },
+ });
+ });
+
+ describe('with invalid value', () => {
+ beforeEach(() => {
+ setValueAndTriggerValidation('');
+ });
+
+ it('should set correct field state', () => {
+ expect(getFormData().fields.exampleField).toEqual({
+ state: false,
+ feedback: expect.any(String),
+ });
+ });
+
+ it('should set correct feedback', () => {
+ expect(getFormData().fields.exampleField.feedback).toBe('Please fill out this field.');
+ });
+ });
+
+ describe('with valid value', () => {
+ beforeEach(() => {
+ setValueAndTriggerValidation('hello');
+ });
+
+ it('set the correct state', () => {
+ expect(getFormData().fields.exampleField).toEqual({
+ state: true,
+ feedback: '',
+ });
+ });
+ });
+ });
+
+ describe('component using initForm', () => {
+ it('sets the form fields correctly', () => {
+ createComponentWithInitForm();
+
+ expect(getFormData().state).toBe(false);
+ expect(getFormData().showValidation).toBe(false);
+
+ expect(getFormData().fields.exampleField).toMatchObject({
+ value: 'lorem',
+ state: null,
+ required: true,
+ feedback: expect.any(String),
+ });
+ });
+ });
+});
+
+describe('initForm', () => {
+ const MOCK_FORM = {
+ fields: {
+ name: {
+ value: 'lorem',
+ },
+ description: {
+ value: 'ipsum',
+ required: false,
+ skipValidation: true,
+ },
+ },
+ };
+
+ const EXPECTED_FIELDS = {
+ name: { value: 'lorem', required: true, state: null, feedback: null },
+ description: { value: 'ipsum', required: false, state: true, feedback: null },
+ };
+
+ it('returns form object', () => {
+ expect(initForm(MOCK_FORM)).toMatchObject({
+ state: false,
+ showValidation: false,
+ fields: EXPECTED_FIELDS,
+ });
+ });
+
+ it('returns form object with additional parameters', () => {
+ const customFormObject = {
+ foo: {
+ bar: 'lorem',
+ },
+ };
+
+ const form = {
+ ...MOCK_FORM,
+ ...customFormObject,
+ };
+
+ expect(initForm(form)).toMatchObject({
+ state: false,
+ showValidation: false,
+ fields: EXPECTED_FIELDS,
+ ...customFormObject,
+ });
+ });
+
+ it('can override existing state and showValidation values', () => {
+ const form = {
+ ...MOCK_FORM,
+ state: true,
+ showValidation: true,
+ };
+
+ expect(initForm(form)).toMatchObject({
+ state: true,
+ showValidation: true,
+ fields: EXPECTED_FIELDS,
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js
new file mode 100644
index 00000000000..52f36aa0e77
--- /dev/null
+++ b/spec/frontend/vue_shared/new_namespace/components/legacy_container_spec.js
@@ -0,0 +1,63 @@
+import { shallowMount } from '@vue/test-utils';
+import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures';
+import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue';
+
+describe('Legacy container component', () => {
+ let wrapper;
+ let dummy;
+
+ const createComponent = (propsData) => {
+ wrapper = shallowMount(LegacyContainer, { propsData });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ resetHTMLFixture();
+ wrapper = null;
+ });
+
+ describe('when selector targets real node', () => {
+ beforeEach(() => {
+ setHTMLFixture('<div class="dummy-target"></div>');
+ dummy = document.querySelector('.dummy-target');
+ createComponent({ selector: '.dummy-target' });
+ });
+
+ describe('when mounted', () => {
+ it('moves node inside component', () => {
+ expect(dummy.parentNode).toBe(wrapper.element);
+ });
+
+ it('sets active class', () => {
+ expect(dummy.classList.contains('active')).toBe(true);
+ });
+ });
+
+ describe('when unmounted', () => {
+ beforeEach(() => {
+ wrapper.destroy();
+ });
+
+ it('moves node back', () => {
+ expect(dummy.parentNode).toBe(document.body);
+ });
+
+ it('removes active class', () => {
+ expect(dummy.classList.contains('active')).toBe(false);
+ });
+ });
+ });
+
+ describe('when selector targets template node', () => {
+ beforeEach(() => {
+ setHTMLFixture('<template class="dummy-target">content</template>');
+ dummy = document.querySelector('.dummy-target');
+ createComponent({ selector: '.dummy-target' });
+ });
+
+ it('copies node content when mounted', () => {
+ expect(dummy.innerHTML).toEqual(wrapper.element.innerHTML);
+ expect(dummy.parentNode).toBe(document.body);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js
new file mode 100644
index 00000000000..602213fca83
--- /dev/null
+++ b/spec/frontend/vue_shared/new_namespace/components/welcome_spec.js
@@ -0,0 +1,78 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import { mockTracking } from 'helpers/tracking_helper';
+import { TRACKING_CONTEXT_SCHEMA } from '~/experimentation/constants';
+import { getExperimentData } from '~/experimentation/utils';
+import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue';
+
+jest.mock('~/experimentation/utils', () => ({ getExperimentData: jest.fn() }));
+
+describe('Welcome page', () => {
+ let wrapper;
+ let trackingSpy;
+
+ const DEFAULT_PROPS = {
+ title: 'Create new something',
+ };
+
+ const createComponent = ({ propsData, slots }) => {
+ wrapper = shallowMount(WelcomePage, {
+ slots,
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...propsData,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ trackingSpy = mockTracking('_category_', document, jest.spyOn);
+ trackingSpy.mockImplementation(() => {});
+ getExperimentData.mockReturnValue(undefined);
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ window.location.hash = '';
+ wrapper = null;
+ });
+
+ it('tracks link clicks', async () => {
+ createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } });
+ const link = wrapper.find('a');
+ link.trigger('click');
+ await nextTick();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', { label: 'test' });
+ });
+ });
+
+ it('adds experiment data if in experiment', async () => {
+ const mockExperimentData = 'data';
+ getExperimentData.mockReturnValue(mockExperimentData);
+
+ createComponent({ propsData: { experiment: 'foo', panels: [{ name: 'test', href: '#' }] } });
+ const link = wrapper.find('a');
+ link.trigger('click');
+ await nextTick();
+ return wrapper.vm.$nextTick().then(() => {
+ expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_tab', {
+ label: 'test',
+ context: {
+ data: mockExperimentData,
+ schema: TRACKING_CONTEXT_SCHEMA,
+ },
+ });
+ });
+ });
+
+ it('renders footer slot if provided', () => {
+ const DUMMY = 'Test message';
+ createComponent({
+ slots: { footer: DUMMY },
+ propsData: { panels: [{ name: 'test', href: '#' }] },
+ });
+
+ expect(wrapper.text()).toContain(DUMMY);
+ });
+});
diff --git a/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
new file mode 100644
index 00000000000..30937921900
--- /dev/null
+++ b/spec/frontend/vue_shared/new_namespace/new_namespace_page_spec.js
@@ -0,0 +1,114 @@
+import { GlBreadcrumb } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import LegacyContainer from '~/vue_shared/new_namespace/components/legacy_container.vue';
+import WelcomePage from '~/vue_shared/new_namespace/components/welcome.vue';
+import NewNamespacePage from '~/vue_shared/new_namespace/new_namespace_page.vue';
+
+describe('Experimental new project creation app', () => {
+ let wrapper;
+
+ const findWelcomePage = () => wrapper.findComponent(WelcomePage);
+ const findLegacyContainer = () => wrapper.findComponent(LegacyContainer);
+ const findBreadcrumb = () => wrapper.findComponent(GlBreadcrumb);
+
+ const DEFAULT_PROPS = {
+ title: 'Create something',
+ initialBreadcrumb: 'Something',
+ panels: [
+ { name: 'panel1', selector: '#some-selector1' },
+ { name: 'panel2', selector: '#some-selector2' },
+ ],
+ persistenceKey: 'DEMO-PERSISTENCE-KEY',
+ };
+
+ const createComponent = ({ slots, propsData } = {}) => {
+ wrapper = shallowMount(NewNamespacePage, {
+ slots,
+ propsData: {
+ ...DEFAULT_PROPS,
+ ...propsData,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ window.location.hash = '';
+ });
+
+ it('passes experiment to welcome component if provided', () => {
+ const EXPERIMENT = 'foo';
+ createComponent({ propsData: { experiment: EXPERIMENT } });
+
+ expect(findWelcomePage().props().experiment).toBe(EXPERIMENT);
+ });
+
+ describe('with empty hash', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('renders welcome page', () => {
+ expect(findWelcomePage().exists()).toBe(true);
+ });
+
+ it('does not render breadcrumbs', () => {
+ expect(findBreadcrumb().exists()).toBe(false);
+ });
+ });
+
+ it('renders first container if jumpToLastPersistedPanel passed', () => {
+ createComponent({ propsData: { jumpToLastPersistedPanel: true } });
+ expect(findWelcomePage().exists()).toBe(false);
+ expect(findLegacyContainer().exists()).toBe(true);
+ });
+
+ describe('when hash is not empty on load', () => {
+ beforeEach(() => {
+ window.location.hash = `#${DEFAULT_PROPS.panels[1].name}`;
+ createComponent();
+ });
+
+ it('renders relevant container', () => {
+ expect(findWelcomePage().exists()).toBe(false);
+
+ const container = findLegacyContainer();
+
+ expect(container.exists()).toBe(true);
+ expect(container.props().selector).toBe(DEFAULT_PROPS.panels[1].selector);
+ });
+
+ it('renders breadcrumbs', () => {
+ const breadcrumb = findBreadcrumb();
+ expect(breadcrumb.exists()).toBe(true);
+ expect(breadcrumb.props().items[0].text).toBe(DEFAULT_PROPS.initialBreadcrumb);
+ });
+ });
+
+ it('renders extra description if provided', () => {
+ window.location.hash = `#${DEFAULT_PROPS.panels[1].name}`;
+ const EXTRA_DESCRIPTION = 'Some extra description';
+ createComponent({
+ slots: {
+ 'extra-description': EXTRA_DESCRIPTION,
+ },
+ });
+
+ expect(wrapper.text()).toContain(EXTRA_DESCRIPTION);
+ });
+
+ it('renders relevant container when hash changes', async () => {
+ createComponent();
+ expect(findWelcomePage().exists()).toBe(true);
+
+ window.location.hash = `#${DEFAULT_PROPS.panels[0].name}`;
+ const ev = document.createEvent('HTMLEvents');
+ ev.initEvent('hashchange', false, false);
+ window.dispatchEvent(ev);
+
+ await nextTick();
+ expect(findWelcomePage().exists()).toBe(false);
+ expect(findLegacyContainer().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js b/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js
new file mode 100644
index 00000000000..066f9a57bc6
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/components/apollo_mocks.js
@@ -0,0 +1,12 @@
+export const buildConfigureSecurityFeatureMockFactory = (mutationType) => ({
+ successPath = 'testSuccessPath',
+ errors = [],
+} = {}) => ({
+ data: {
+ [mutationType]: {
+ successPath,
+ errors,
+ __typename: `${mutationType}Payload`,
+ },
+ },
+});
diff --git a/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
new file mode 100644
index 00000000000..517eee6a729
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/components/manage_via_mr_spec.js
@@ -0,0 +1,184 @@
+import { GlButton } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import Vue from 'vue';
+import VueApollo from 'vue-apollo';
+import { featureToMutationMap } from 'ee_else_ce/security_configuration/components/constants';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { extendedWrapper } from 'helpers/vue_test_utils_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import { humanize } from '~/lib/utils/text_utility';
+import { redirectTo } from '~/lib/utils/url_utility';
+import ManageViaMr from '~/vue_shared/security_configuration/components/manage_via_mr.vue';
+import { buildConfigureSecurityFeatureMockFactory } from './apollo_mocks';
+
+jest.mock('~/lib/utils/url_utility');
+
+Vue.use(VueApollo);
+
+const projectPath = 'namespace/project';
+
+describe('ManageViaMr component', () => {
+ let wrapper;
+
+ const findButton = () => wrapper.findComponent(GlButton);
+
+ function createMockApolloProvider(mutation, handler) {
+ const requestHandlers = [[mutation, handler]];
+
+ return createMockApollo(requestHandlers);
+ }
+
+ function createComponent({
+ featureName = 'SAST',
+ featureType = 'sast',
+ isFeatureConfigured = false,
+ variant = undefined,
+ category = undefined,
+ ...options
+ } = {}) {
+ wrapper = extendedWrapper(
+ mount(ManageViaMr, {
+ provide: {
+ projectPath,
+ },
+ propsData: {
+ feature: {
+ name: featureName,
+ type: featureType,
+ configured: isFeatureConfigured,
+ },
+ variant,
+ category,
+ },
+ ...options,
+ }),
+ );
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ // This component supports different report types/mutations depending on
+ // whether it's in a CE or EE context. This makes sure we are only testing
+ // the ones available in the current test context.
+ const supportedReportTypes = Object.entries(featureToMutationMap).map(
+ ([featureType, { getMutationPayload, mutationId }]) => {
+ const { mutation, variables: mutationVariables } = getMutationPayload(projectPath);
+ return [humanize(featureType), featureType, mutation, mutationId, mutationVariables];
+ },
+ );
+
+ describe.each(supportedReportTypes)(
+ '%s',
+ (featureName, featureType, mutation, mutationId, mutationVariables) => {
+ const buildConfigureSecurityFeatureMock = buildConfigureSecurityFeatureMockFactory(
+ mutationId,
+ );
+ const successHandler = jest.fn(async () => buildConfigureSecurityFeatureMock());
+ const noSuccessPathHandler = async () =>
+ buildConfigureSecurityFeatureMock({
+ successPath: '',
+ });
+ const errorHandler = async () =>
+ buildConfigureSecurityFeatureMock({
+ errors: ['foo'],
+ });
+ const pendingHandler = () => new Promise(() => {});
+
+ describe('when feature is configured', () => {
+ beforeEach(() => {
+ const apolloProvider = createMockApolloProvider(mutation, successHandler);
+ createComponent({ apolloProvider, featureName, featureType, isFeatureConfigured: true });
+ });
+
+ it('it does not render a button', () => {
+ expect(findButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when feature is not configured', () => {
+ beforeEach(() => {
+ const apolloProvider = createMockApolloProvider(mutation, successHandler);
+ createComponent({ apolloProvider, featureName, featureType, isFeatureConfigured: false });
+ });
+
+ it('it does render a button', () => {
+ expect(findButton().exists()).toBe(true);
+ });
+
+ it('clicking on the button triggers the configure mutation', () => {
+ findButton().trigger('click');
+
+ expect(successHandler).toHaveBeenCalledTimes(1);
+ expect(successHandler).toHaveBeenCalledWith(mutationVariables);
+ });
+ });
+
+ describe('given a pending response', () => {
+ beforeEach(() => {
+ const apolloProvider = createMockApolloProvider(mutation, pendingHandler);
+ createComponent({ apolloProvider, featureName, featureType });
+ });
+
+ it('renders spinner correctly', async () => {
+ const button = findButton();
+ expect(button.props('loading')).toBe(false);
+ await button.trigger('click');
+ expect(button.props('loading')).toBe(true);
+ });
+ });
+
+ describe('given a successful response', () => {
+ beforeEach(() => {
+ const apolloProvider = createMockApolloProvider(mutation, successHandler);
+ createComponent({ apolloProvider, featureName, featureType });
+ });
+
+ it('should call redirect helper with correct value', async () => {
+ await wrapper.trigger('click');
+ await waitForPromises();
+ expect(redirectTo).toHaveBeenCalledTimes(1);
+ expect(redirectTo).toHaveBeenCalledWith('testSuccessPath');
+ // This is done for UX reasons. If the loading prop is set to false
+ // on success, then there's a period where the button is clickable
+ // again. Instead, we want the button to display a loading indicator
+ // for the remainder of the lifetime of the page (i.e., until the
+ // browser can start painting the new page it's been redirected to).
+ expect(findButton().props().loading).toBe(true);
+ });
+ });
+
+ describe.each`
+ handler | message
+ ${noSuccessPathHandler} | ${`${featureName} merge request creation mutation failed`}
+ ${errorHandler} | ${'foo'}
+ `('given an error response', ({ handler, message }) => {
+ beforeEach(() => {
+ const apolloProvider = createMockApolloProvider(mutation, handler);
+ createComponent({ apolloProvider, featureName, featureType });
+ });
+
+ it('should catch and emit error', async () => {
+ await wrapper.trigger('click');
+ await waitForPromises();
+ expect(wrapper.emitted('error')).toEqual([[message]]);
+ expect(findButton().props('loading')).toBe(false);
+ });
+ });
+ },
+ );
+
+ describe('button props', () => {
+ it('passes the variant and category props to the GlButton', () => {
+ const variant = 'danger';
+ const category = 'tertiary';
+ createComponent({ variant, category });
+
+ expect(wrapper.findComponent(GlButton).props()).toMatchObject({
+ variant,
+ category,
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/security_reports/mock_data.js b/spec/frontend/vue_shared/security_reports/mock_data.js
index 7918f70d702..bd9ce3b7314 100644
--- a/spec/frontend/vue_shared/security_reports/mock_data.js
+++ b/spec/frontend/vue_shared/security_reports/mock_data.js
@@ -322,7 +322,7 @@ export const secretScanningDiffSuccessMock = {
head_report_created_at: '2020-01-10T10:00:00.000Z',
};
-export const securityReportDownloadPathsQueryNoArtifactsResponse = {
+export const securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse = {
project: {
mergeRequest: {
headPipeline: {
@@ -339,7 +339,7 @@ export const securityReportDownloadPathsQueryNoArtifactsResponse = {
},
};
-export const securityReportDownloadPathsQueryResponse = {
+export const securityReportMergeRequestDownloadPathsQueryResponse = {
project: {
mergeRequest: {
headPipeline: {
@@ -447,8 +447,114 @@ export const securityReportDownloadPathsQueryResponse = {
},
};
+export const securityReportPipelineDownloadPathsQueryResponse = {
+ project: {
+ pipeline: {
+ id: 'gid://gitlab/Ci::Pipeline/176',
+ jobs: {
+ nodes: [
+ {
+ name: 'secret_detection',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1399/artifacts/download?file_type=secret_detection',
+ fileType: 'SECRET_DETECTION',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ {
+ name: 'bandit-sast',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1400/artifacts/download?file_type=sast',
+ fileType: 'SAST',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ {
+ name: 'eslint-sast',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1401/artifacts/download?file_type=sast',
+ fileType: 'SAST',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ {
+ name: 'all_artifacts',
+ artifacts: {
+ nodes: [
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=archive',
+ fileType: 'ARCHIVE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=trace',
+ fileType: 'TRACE',
+ __typename: 'CiJobArtifact',
+ },
+ {
+ downloadPath:
+ '/gitlab-org/secrets-detection-test/-/jobs/1402/artifacts/download?file_type=metadata',
+ fileType: 'METADATA',
+ __typename: 'CiJobArtifact',
+ },
+ ],
+ __typename: 'CiJobArtifactConnection',
+ },
+ __typename: 'CiJob',
+ },
+ ],
+ __typename: 'CiJobConnection',
+ },
+ __typename: 'Pipeline',
+ },
+ __typename: 'MergeRequest',
+ },
+ __typename: 'Project',
+};
+
/**
- * These correspond to SAST jobs in the securityReportDownloadPathsQueryResponse above.
+ * These correspond to SAST jobs in the securityReportMergeRequestDownloadPathsQueryResponse above.
*/
export const sastArtifacts = [
{
@@ -464,7 +570,7 @@ export const sastArtifacts = [
];
/**
- * These correspond to Secret Detection jobs in the securityReportDownloadPathsQueryResponse above.
+ * These correspond to Secret Detection jobs in the securityReportMergeRequestDownloadPathsQueryResponse above.
*/
export const secretDetectionArtifacts = [
{
@@ -481,7 +587,7 @@ export const expectedDownloadDropdownProps = {
};
/**
- * These correspond to any jobs with zip archives in the securityReportDownloadPathsQueryResponse above.
+ * These correspond to any jobs with zip archives in the securityReportMergeRequestDownloadPathsQueryResponse above.
*/
export const archiveArtifacts = [
{
@@ -492,7 +598,7 @@ export const archiveArtifacts = [
];
/**
- * These correspond to any jobs with trace data in the securityReportDownloadPathsQueryResponse above.
+ * These correspond to any jobs with trace data in the securityReportMergeRequestDownloadPathsQueryResponse above.
*/
export const traceArtifacts = [
{
@@ -518,7 +624,7 @@ export const traceArtifacts = [
];
/**
- * These correspond to any jobs with metadata data in the securityReportDownloadPathsQueryResponse above.
+ * These correspond to any jobs with metadata data in the securityReportMergeRequestDownloadPathsQueryResponse above.
*/
export const metadataArtifacts = [
{
diff --git a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
index 0b4816a951e..038d7754776 100644
--- a/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
+++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
@@ -9,8 +9,8 @@ import { trimText } from 'helpers/text_helper';
import waitForPromises from 'helpers/wait_for_promises';
import {
expectedDownloadDropdownProps,
- securityReportDownloadPathsQueryNoArtifactsResponse,
- securityReportDownloadPathsQueryResponse,
+ securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse,
+ securityReportMergeRequestDownloadPathsQueryResponse,
sastDiffSuccessMock,
secretScanningDiffSuccessMock,
} from 'jest/vue_shared/security_reports/mock_data';
@@ -22,7 +22,7 @@ import {
REPORT_TYPE_SAST,
REPORT_TYPE_SECRET_DETECTION,
} from '~/vue_shared/security_reports/constants';
-import securityReportDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_download_paths.query.graphql';
+import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql';
import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
jest.mock('~/flash');
@@ -59,12 +59,13 @@ describe('Security reports app', () => {
};
const pendingHandler = () => new Promise(() => {});
- const successHandler = () => Promise.resolve({ data: securityReportDownloadPathsQueryResponse });
+ const successHandler = () =>
+ Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryResponse });
const successEmptyHandler = () =>
- Promise.resolve({ data: securityReportDownloadPathsQueryNoArtifactsResponse });
+ Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryNoArtifactsResponse });
const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] });
const createMockApolloProvider = (handler) => {
- const requestHandlers = [[securityReportDownloadPathsQuery, handler]];
+ const requestHandlers = [[securityReportMergeRequestDownloadPathsQuery, handler]];
return createMockApollo(requestHandlers);
};
diff --git a/spec/frontend/vue_shared/security_reports/utils_spec.js b/spec/frontend/vue_shared/security_reports/utils_spec.js
index aa9e54fa10c..b7129ece698 100644
--- a/spec/frontend/vue_shared/security_reports/utils_spec.js
+++ b/spec/frontend/vue_shared/security_reports/utils_spec.js
@@ -3,9 +3,13 @@ import {
REPORT_TYPE_SECRET_DETECTION,
REPORT_FILE_TYPES,
} from '~/vue_shared/security_reports/constants';
-import { extractSecurityReportArtifacts } from '~/vue_shared/security_reports/utils';
import {
- securityReportDownloadPathsQueryResponse,
+ extractSecurityReportArtifactsFromMergeRequest,
+ extractSecurityReportArtifactsFromPipeline,
+} from '~/vue_shared/security_reports/utils';
+import {
+ securityReportMergeRequestDownloadPathsQueryResponse,
+ securityReportPipelineDownloadPathsQueryResponse,
sastArtifacts,
secretDetectionArtifacts,
archiveArtifacts,
@@ -13,7 +17,18 @@ import {
metadataArtifacts,
} from './mock_data';
-describe('extractSecurityReportArtifacts', () => {
+describe.each([
+ [
+ 'extractSecurityReportArtifactsFromMergeRequest',
+ extractSecurityReportArtifactsFromMergeRequest,
+ securityReportMergeRequestDownloadPathsQueryResponse,
+ ],
+ [
+ 'extractSecurityReportArtifactsFromPipelines',
+ extractSecurityReportArtifactsFromPipeline,
+ securityReportPipelineDownloadPathsQueryResponse,
+ ],
+])('%s', (funcName, extractFunc, response) => {
it.each`
reportTypes | expectedArtifacts
${[]} | ${[]}
@@ -27,9 +42,7 @@ describe('extractSecurityReportArtifacts', () => {
`(
'returns the expected artifacts given report types $reportTypes',
({ reportTypes, expectedArtifacts }) => {
- expect(
- extractSecurityReportArtifacts(reportTypes, securityReportDownloadPathsQueryResponse),
- ).toEqual(expectedArtifacts);
+ expect(extractFunc(reportTypes, response)).toEqual(expectedArtifacts);
},
);
});