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

gitlab.com/gitlab-org/gitlab-foss.git - Unnamed repository; edit this file 'description' to name the repository.
summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 18:44:42 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2021-05-19 18:44:42 +0300
commit4555e1b21c365ed8303ffb7a3325d773c9b8bf31 (patch)
tree5423a1c7516cffe36384133ade12572cf709398d /spec/frontend/vue_shared/components
parente570267f2f6b326480d284e0164a6464ba4081bc (diff)
Add latest changes from gitlab-org/gitlab@13-12-stable-eev13.12.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared/components')
-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
24 files changed, 1190 insertions, 117 deletions
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);
+ });
+});