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>2020-10-21 10:08:36 +0300
committerGitLab Bot <gitlab-bot@gitlab.com>2020-10-21 10:08:36 +0300
commit48aff82709769b098321c738f3444b9bdaa694c6 (patch)
treee00c7c43e2d9b603a5a6af576b1685e400410dee /spec/frontend/vue_shared
parent879f5329ee916a948223f8f43d77fba4da6cd028 (diff)
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared')
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap8
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap2
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap14
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap8
-rw-r--r--spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap37
-rw-r--r--spec/frontend/vue_shared/components/actions_button_spec.js28
-rw-r--r--spec/frontend/vue_shared/components/alert_detail_table_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/alert_details_table_spec.js139
-rw-r--r--spec/frontend/vue_shared/components/clipboard_button_spec.js5
-rw-r--r--spec/frontend/vue_shared/components/confirm_modal_spec.js16
-rw-r--r--spec/frontend/vue_shared/components/deprecated_modal_2_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/editor_lite_spec.js144
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js448
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js50
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js116
-rw-r--r--spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js11
-rw-r--r--spec/frontend/vue_shared/components/local_storage_sync_spec.js150
-rw-r--r--spec/frontend/vue_shared/components/markdown/__snapshots__/suggestion_diff_spec.js.snap1
-rw-r--r--spec/frontend/vue_shared/components/markdown/field_spec.js213
-rw-r--r--spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js108
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js74
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js85
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js59
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js64
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js66
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js66
-rw-r--r--spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js89
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js46
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js38
-rw-r--r--spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js115
-rw-r--r--spec/frontend/vue_shared/components/members/mock_data.js70
-rw-r--r--spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js91
-rw-r--r--spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js106
-rw-r--r--spec/frontend/vue_shared/components/members/table/created_at_spec.js61
-rw-r--r--spec/frontend/vue_shared/components/members/table/expires_at_spec.js86
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js43
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_avatar_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_source_spec.js71
-rw-r--r--spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js251
-rw-r--r--spec/frontend/vue_shared/components/members/table/members_table_spec.js141
-rw-r--r--spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js150
-rw-r--r--spec/frontend/vue_shared/components/members/utils_spec.js29
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json15
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json14
-rw-r--r--spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js350
-rw-r--r--spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap7
-rw-r--r--spec/frontend/vue_shared/components/registry/list_item_spec.js8
-rw-r--r--spec/frontend/vue_shared/components/registry/title_area_spec.js39
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js56
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js44
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js41
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js27
-rw-r--r--spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js23
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js7
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js15
-rw-r--r--spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js13
-rw-r--r--spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js11
-rw-r--r--spec/frontend/vue_shared/components/split_button_spec.js6
-rw-r--r--spec/frontend/vue_shared/components/todo_button_spec.js2
-rw-r--r--spec/frontend/vue_shared/components/user_popover/user_popover_spec.js30
-rw-r--r--spec/frontend/vue_shared/components/web_ide_link_spec.js71
-rw-r--r--spec/frontend/vue_shared/directives/tooltip_spec.js169
-rw-r--r--spec/frontend/vue_shared/droplab_dropdown_button_spec.js132
-rw-r--r--spec/frontend/vue_shared/security_reports/security_reports_app_spec.js118
69 files changed, 4159 insertions, 478 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
index 19671d425a9..82503e5a025 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap
@@ -228,9 +228,11 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = `
/>
</span>
- <i
- aria-hidden="true"
- class="fa fa-spinner fa-spin award-control-icon award-control-icon-loading"
+ <gl-loading-icon-stub
+ class="award-control-icon-loading"
+ color="dark"
+ label="Loading"
+ size="md"
/>
</button>
</div>
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
index dfd114a2d1c..ec4a81054db 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap
@@ -39,6 +39,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="d-inline-flex"
data-clipboard-text="ssh://foo.bar"
@@ -80,6 +81,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = `
tag="div"
>
<gl-button-stub
+ buttontextclasses=""
category="primary"
class="d-inline-flex"
data-clipboard-text="http://foo.bar"
diff --git a/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap
new file mode 100644
index 00000000000..26785855369
--- /dev/null
+++ b/spec/frontend/vue_shared/components/__snapshots__/editor_lite_spec.js.snap
@@ -0,0 +1,14 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Editor Lite component rendering matches the snapshot 1`] = `
+<div
+ data-editor-loading=""
+ id="editor-lite-snippet_777"
+>
+ <pre
+ class="editor-loading-content"
+ >
+ Lorem ipsum dolor sit amet, consectetur adipiscing elit.
+ </pre>
+</div>
+`;
diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
index c2b97f1e7f9..19a649089e0 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap
@@ -11,7 +11,7 @@ exports[`Expand button on click when short text is provided renders button after
<!---->
<svg
- class="gl-icon s16"
+ class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
<use
@@ -39,7 +39,7 @@ exports[`Expand button on click when short text is provided renders button after
<!---->
<svg
- class="gl-icon s16"
+ class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
<use
@@ -62,7 +62,7 @@ exports[`Expand button when short text is provided renders button before text 1`
<!---->
<svg
- class="gl-icon s16"
+ class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
<use
@@ -90,7 +90,7 @@ exports[`Expand button when short text is provided renders button before text 1`
<!---->
<svg
- class="gl-icon s16"
+ class="gl-button-icon gl-icon s16"
data-testid="ellipsis_h-icon"
>
<use
diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
index fcb9c4b8b02..8eb0e8f9550 100644
--- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
+++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap
@@ -1,15 +1,23 @@
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`SplitButton renders actionItems 1`] = `
-<gl-deprecated-dropdown-stub
- menu-class="dropdown-menu-selectable "
+<gl-dropdown-stub
+ category="tertiary"
+ headertext=""
+ menu-class=""
+ size="medium"
split="true"
text="professor"
- variant="secondary"
+ variant="default"
>
- <gl-deprecated-dropdown-item-stub
- active="true"
- active-class="is-active"
+ <gl-dropdown-item-stub
+ avatarurl=""
+ iconcolor=""
+ iconname=""
+ iconrightname=""
+ ischecked="true"
+ ischeckitem="true"
+ secondarytext=""
>
<strong>
professor
@@ -18,11 +26,16 @@ exports[`SplitButton renders actionItems 1`] = `
<div>
very symphonic
</div>
- </gl-deprecated-dropdown-item-stub>
+ </gl-dropdown-item-stub>
- <gl-deprecated-dropdown-divider-stub />
- <gl-deprecated-dropdown-item-stub
- active-class="is-active"
+ <gl-dropdown-divider-stub />
+ <gl-dropdown-item-stub
+ avatarurl=""
+ iconcolor=""
+ iconname=""
+ iconrightname=""
+ ischeckitem="true"
+ secondarytext=""
>
<strong>
captain
@@ -31,8 +44,8 @@ exports[`SplitButton renders actionItems 1`] = `
<div>
warp drive
</div>
- </gl-deprecated-dropdown-item-stub>
+ </gl-dropdown-item-stub>
<!---->
-</gl-deprecated-dropdown-stub>
+</gl-dropdown-stub>
`;
diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js
index 4dde9d726d1..6e7ed9d612b 100644
--- a/spec/frontend/vue_shared/components/actions_button_spec.js
+++ b/spec/frontend/vue_shared/components/actions_button_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDropdown, GlLink } from '@gitlab/ui';
+import { GlDropdown, GlButton } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
@@ -9,7 +9,12 @@ const TEST_ACTION = {
secondaryText: 'Lorem ipsum.',
tooltip: '',
href: '/sample',
- attrs: { 'data-test': '123' },
+ attrs: {
+ 'data-test': '123',
+ category: 'secondary',
+ href: '/sample',
+ variant: 'default',
+ },
};
const TEST_ACTION_2 = {
key: 'action2',
@@ -40,8 +45,8 @@ describe('Actions button component', () => {
return directiveBinding.value;
};
- const findLink = () => wrapper.find(GlLink);
- const findLinkTooltip = () => getTooltip(findLink());
+ const findButton = () => wrapper.find(GlButton);
+ const findButtonTooltip = () => getTooltip(findButton());
const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownTooltip = () => getTooltip(findDropdown());
const parseDropdownItems = () =>
@@ -63,7 +68,7 @@ describe('Actions button component', () => {
};
});
const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt);
- const clickLink = (...args) => clickOn(findLink(), ...args);
+ const clickLink = (...args) => clickOn(findButton(), ...args);
const clickDropdown = (...args) => clickOn(findDropdown(), ...args);
describe('with 1 action', () => {
@@ -76,22 +81,19 @@ describe('Actions button component', () => {
});
it('should render single button', () => {
- const link = findLink();
-
- expect(link.attributes()).toEqual({
- class: expect.any(String),
+ expect(findButton().attributes()).toMatchObject({
href: TEST_ACTION.href,
...TEST_ACTION.attrs,
});
- expect(link.text()).toBe(TEST_ACTION.text);
+ expect(findButton().text()).toBe(TEST_ACTION.text);
});
it('should have tooltip', () => {
- expect(findLinkTooltip()).toBe(TEST_ACTION.tooltip);
+ expect(findButtonTooltip()).toBe(TEST_ACTION.tooltip);
});
it('should have attrs', () => {
- expect(findLink().attributes()).toMatchObject(TEST_ACTION.attrs);
+ expect(findButton().attributes()).toMatchObject(TEST_ACTION.attrs);
});
it('can click', () => {
@@ -103,7 +105,7 @@ describe('Actions button component', () => {
it('should have tooltip', () => {
createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] });
- expect(findLinkTooltip()).toBe(TEST_TOOLTIP);
+ expect(findButtonTooltip()).toBe(TEST_TOOLTIP);
});
});
diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_detail_table_spec.js
deleted file mode 100644
index 9c38ccad8a7..00000000000
--- a/spec/frontend/vue_shared/components/alert_detail_table_spec.js
+++ /dev/null
@@ -1,74 +0,0 @@
-import { mount } from '@vue/test-utils';
-import { GlTable, GlLoadingIcon } from '@gitlab/ui';
-import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
-
-const mockAlert = {
- iid: '1527542',
- title: 'SyntaxError: Invalid or unexpected token',
- severity: 'CRITICAL',
- eventCount: 7,
- createdAt: '2020-04-17T23:18:14.996Z',
- startedAt: '2020-04-17T23:18:14.996Z',
- endedAt: '2020-04-17T23:18:14.996Z',
- status: 'TRIGGERED',
- assignees: { nodes: [] },
- notes: { nodes: [] },
- todos: { nodes: [] },
-};
-
-describe('AlertDetails', () => {
- let wrapper;
-
- function mountComponent(propsData = {}) {
- wrapper = mount(AlertDetailsTable, {
- propsData: {
- alert: mockAlert,
- loading: false,
- ...propsData,
- },
- });
- }
-
- afterEach(() => {
- wrapper.destroy();
- wrapper = null;
- });
-
- const findTableComponent = () => wrapper.find(GlTable);
-
- describe('Alert details', () => {
- describe('empty state', () => {
- beforeEach(() => {
- mountComponent({ alert: null });
- });
-
- it('shows an empty state when no alert is provided', () => {
- expect(wrapper.text()).toContain('No alert data to display.');
- });
- });
-
- describe('loading state', () => {
- beforeEach(() => {
- mountComponent({ loading: true });
- });
-
- it('displays a loading state when loading', () => {
- expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
- });
- });
-
- describe('with table data', () => {
- beforeEach(() => {
- mountComponent();
- });
-
- it('renders a table', () => {
- expect(findTableComponent().exists()).toBe(true);
- });
-
- it('renders a cell based on alert data', () => {
- expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
- });
- });
- });
-});
diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js
new file mode 100644
index 00000000000..dff307e92c2
--- /dev/null
+++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js
@@ -0,0 +1,139 @@
+import { GlLoadingIcon, GlTable } from '@gitlab/ui';
+import { mount } from '@vue/test-utils';
+import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue';
+
+const mockAlert = {
+ iid: '1527542',
+ title: 'SyntaxError: Invalid or unexpected token',
+ severity: 'CRITICAL',
+ eventCount: 7,
+ createdAt: '2020-04-17T23:18:14.996Z',
+ startedAt: '2020-04-17T23:18:14.996Z',
+ endedAt: '2020-04-17T23:18:14.996Z',
+ status: 'TRIGGERED',
+ assignees: { nodes: [] },
+ notes: { nodes: [] },
+ todos: { nodes: [] },
+ hosts: ['host1', 'host2'],
+ __typename: 'AlertManagementAlert',
+};
+
+const environmentName = 'Production';
+const environmentPath = '/fake/path';
+
+describe('AlertDetails', () => {
+ let environmentData = { name: environmentName, path: environmentPath };
+ let glFeatures = { exposeEnvironmentPathInAlertDetails: false };
+ let wrapper;
+
+ function mountComponent(propsData = {}) {
+ wrapper = mount(AlertDetailsTable, {
+ provide: {
+ glFeatures,
+ },
+ propsData: {
+ alert: {
+ ...mockAlert,
+ environment: environmentData,
+ },
+ loading: false,
+ ...propsData,
+ },
+ });
+ }
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ const findTableComponent = () => wrapper.find(GlTable);
+ const findTableKeys = () => findTableComponent().findAll('tbody td:first-child');
+ const findTableFieldValueByKey = fieldKey =>
+ findTableComponent()
+ .findAll('tbody tr')
+ .filter(row => row.text().includes(fieldKey))
+ .at(0)
+ .find('td:nth-child(2)');
+ const findTableField = (fields, fieldName) => fields.filter(row => row.text() === fieldName);
+
+ describe('Alert details', () => {
+ describe('empty state', () => {
+ beforeEach(() => {
+ mountComponent({ alert: null });
+ });
+
+ it('shows an empty state when no alert is provided', () => {
+ expect(wrapper.text()).toContain('No alert data to display.');
+ });
+ });
+
+ describe('loading state', () => {
+ beforeEach(() => {
+ mountComponent({ loading: true });
+ });
+
+ it('displays a loading state when loading', () => {
+ expect(wrapper.find(GlLoadingIcon).exists()).toBe(true);
+ });
+ });
+
+ describe('with table data', () => {
+ beforeEach(mountComponent);
+
+ it('renders a table', () => {
+ expect(findTableComponent().exists()).toBe(true);
+ });
+
+ it('renders a cell based on alert data', () => {
+ expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token');
+ });
+
+ it('should show allowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Iid').exists()).toBe(true);
+ expect(findTableField(fields, 'Title').exists()).toBe(true);
+ expect(findTableField(fields, 'Severity').exists()).toBe(true);
+ expect(findTableField(fields, 'Status').exists()).toBe(true);
+ expect(findTableField(fields, 'Hosts').exists()).toBe(true);
+ expect(findTableField(fields, 'Environment').exists()).toBe(false);
+ });
+
+ it('should not show disallowed and flaggedAllowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Typename').exists()).toBe(false);
+ expect(findTableField(fields, 'Todos').exists()).toBe(false);
+ expect(findTableField(fields, 'Notes').exists()).toBe(false);
+ expect(findTableField(fields, 'Assignees').exists()).toBe(false);
+ expect(findTableField(fields, 'Environment').exists()).toBe(false);
+ });
+ });
+
+ describe('when exposeEnvironmentPathInAlertDetails is enabled', () => {
+ beforeEach(() => {
+ glFeatures = { exposeEnvironmentPathInAlertDetails: true };
+ mountComponent();
+ });
+
+ it('should show flaggedAllowed alert fields', () => {
+ const fields = findTableKeys();
+
+ expect(findTableField(fields, 'Environment').exists()).toBe(true);
+ });
+
+ it('should display only the name for the environment', () => {
+ expect(findTableFieldValueByKey('Iid').text()).toBe('1527542');
+ expect(findTableFieldValueByKey('Environment').text()).toBe(environmentName);
+ });
+
+ it('should not display the environment row if there is not data', () => {
+ environmentData = { name: null, path: null };
+ mountComponent();
+
+ expect(findTableFieldValueByKey('Environment').text()).toBeFalsy();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/clipboard_button_spec.js b/spec/frontend/vue_shared/components/clipboard_button_spec.js
index 7f0b7ba8cf8..51a2653befc 100644
--- a/spec/frontend/vue_shared/components/clipboard_button_spec.js
+++ b/spec/frontend/vue_shared/components/clipboard_button_spec.js
@@ -1,5 +1,5 @@
import { shallowMount } from '@vue/test-utils';
-import { GlDeprecatedButton, GlIcon } from '@gitlab/ui';
+import { GlButton } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
describe('clipboard button', () => {
@@ -26,9 +26,8 @@ describe('clipboard button', () => {
});
it('renders a button for clipboard', () => {
- expect(wrapper.find(GlDeprecatedButton).exists()).toBe(true);
+ expect(wrapper.find(GlButton).exists()).toBe(true);
expect(wrapper.attributes('data-clipboard-text')).toBe('copy me');
- expect(wrapper.find(GlIcon).props('name')).toBe('copy-to-clipboard');
});
it('should have a tooltip with default values', () => {
diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js
index 5d92af64de0..8456ca9d125 100644
--- a/spec/frontend/vue_shared/components/confirm_modal_spec.js
+++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js
@@ -86,6 +86,22 @@ describe('vue_shared/components/confirm_modal', () => {
expect(findForm().element.submit).not.toHaveBeenCalled();
});
+ describe('with handleSubmit prop', () => {
+ const handleSubmit = jest.fn();
+ beforeEach(() => {
+ createComponent({ handleSubmit });
+ findModal().vm.$emit('primary');
+ });
+
+ it('will call handleSubmit', () => {
+ expect(handleSubmit).toHaveBeenCalled();
+ });
+
+ it('does not submit the form', () => {
+ expect(findForm().element.submit).not.toHaveBeenCalled();
+ });
+ });
+
describe('when modal submitted', () => {
beforeEach(() => {
findModal().vm.$emit('primary');
diff --git a/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js
index b201a9acdd4..c37a44df6f8 100644
--- a/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js
+++ b/spec/frontend/vue_shared/components/deprecated_modal_2_spec.js
@@ -78,7 +78,7 @@ describe('DeprecatedModal2', () => {
});
it('sets the primary button text', () => {
- const primaryButton = vm.$el.querySelector('.modal-footer button:last-of-type');
+ const primaryButton = vm.$el.querySelector('.js-modal-primary-action .gl-button-text');
expect(primaryButton.innerHTML.trim()).toBe(props.footerPrimaryButtonText);
});
diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
index efa30bf6605..ec553c52236 100644
--- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
+++ b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js
@@ -29,7 +29,7 @@ describe('DropdownSearchInputComponent', () => {
});
it('renders search icon element', () => {
- expect(wrapper.find('.fa-search.dropdown-input-search').exists()).toBe(true);
+ expect(wrapper.find('.dropdown-input-search[data-testid="search-icon"]').exists()).toBe(true);
});
it('displays custom placeholder text', () => {
diff --git a/spec/frontend/vue_shared/components/editor_lite_spec.js b/spec/frontend/vue_shared/components/editor_lite_spec.js
new file mode 100644
index 00000000000..52502fcf64f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/editor_lite_spec.js
@@ -0,0 +1,144 @@
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import EditorLite from '~/vue_shared/components/editor_lite.vue';
+import Editor from '~/editor/editor_lite';
+
+jest.mock('~/editor/editor_lite');
+
+describe('Editor Lite component', () => {
+ let wrapper;
+ const onDidChangeModelContent = jest.fn();
+ const updateModelLanguage = jest.fn();
+ const getValue = jest.fn();
+ const setValue = jest.fn();
+ const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.';
+ const fileName = 'lorem.txt';
+ const fileGlobalId = 'snippet_777';
+ const createInstanceMock = jest.fn().mockImplementation(() => ({
+ onDidChangeModelContent,
+ updateModelLanguage,
+ getValue,
+ setValue,
+ dispose: jest.fn(),
+ }));
+ Editor.mockImplementation(() => {
+ return {
+ createInstance: createInstanceMock,
+ };
+ });
+ function createComponent(props = {}) {
+ wrapper = shallowMount(EditorLite, {
+ propsData: {
+ value,
+ fileName,
+ fileGlobalId,
+ ...props,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ const triggerChangeContent = val => {
+ getValue.mockReturnValue(val);
+ const [cb] = onDidChangeModelContent.mock.calls[0];
+
+ cb();
+
+ jest.runOnlyPendingTimers();
+ };
+
+ describe('rendering', () => {
+ it('matches the snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('renders content', () => {
+ expect(wrapper.text()).toContain(value);
+ });
+ });
+
+ describe('functionality', () => {
+ it('does not fail without content', () => {
+ const spy = jest.spyOn(global.console, 'error');
+ createComponent({ value: undefined });
+
+ expect(spy).not.toHaveBeenCalled();
+ expect(wrapper.find('[id^="editor-lite-"]').exists()).toBe(true);
+ });
+
+ it('initialises Editor Lite instance', () => {
+ const el = wrapper.find({ ref: 'editor' }).element;
+ expect(createInstanceMock).toHaveBeenCalledWith({
+ el,
+ blobPath: fileName,
+ blobGlobalId: fileGlobalId,
+ blobContent: value,
+ extensions: null,
+ });
+ });
+
+ it('reacts to the changes in fileName', () => {
+ const newFileName = 'ipsum.txt';
+
+ wrapper.setProps({
+ fileName: newFileName,
+ });
+
+ return nextTick().then(() => {
+ expect(updateModelLanguage).toHaveBeenCalledWith(newFileName);
+ });
+ });
+
+ it('registers callback with editor onChangeContent', () => {
+ expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function));
+ });
+
+ it('emits input event when the blob content is changed', () => {
+ expect(wrapper.emitted().input).toBeUndefined();
+
+ triggerChangeContent(value);
+
+ expect(wrapper.emitted().input).toEqual([[value]]);
+ });
+
+ it('emits editor-ready event when the Editor Lite is ready', async () => {
+ const el = wrapper.find({ ref: 'editor' }).element;
+ expect(wrapper.emitted()['editor-ready']).toBeUndefined();
+
+ await el.dispatchEvent(new Event('editor-ready'));
+
+ expect(wrapper.emitted()['editor-ready']).toBeDefined();
+ });
+
+ describe('reaction to the value update', () => {
+ it('reacts to the changes in the passed value', async () => {
+ const newValue = 'New Value';
+
+ wrapper.setProps({
+ value: newValue,
+ });
+
+ await nextTick();
+ expect(setValue).toHaveBeenCalledWith(newValue);
+ });
+
+ it("does not update value if the passed one is exactly the same as the editor's content", async () => {
+ const newValue = `${value}`; // to make sure we're creating a new String with the same content and not just a reference
+
+ wrapper.setProps({
+ value: newValue,
+ });
+
+ await nextTick();
+ expect(setValue).not.toHaveBeenCalled();
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js
new file mode 100644
index 00000000000..1dd5f08e76a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js
@@ -0,0 +1,448 @@
+import axios from 'axios';
+import MockAdapter from 'axios-mock-adapter';
+import testAction from 'helpers/vuex_action_helper';
+import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data';
+import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions';
+import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types';
+import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state';
+import httpStatusCodes from '~/lib/utils/http_status';
+import { deprecatedCreateFlash as createFlash } from '~/flash';
+import Api from '~/api';
+import { filterMilestones, filterUsers, filterLabels } from './mock_data';
+
+const milestonesEndpoint = 'fake_milestones_endpoint';
+const labelsEndpoint = 'fake_labels_endpoint';
+const groupEndpoint = 'fake_group_endpoint';
+const projectEndpoint = 'fake_project_endpoint';
+
+jest.mock('~/flash');
+
+describe('Filters actions', () => {
+ let state;
+ let mock;
+ let mockDispatch;
+ let mockCommit;
+
+ beforeEach(() => {
+ state = initialState();
+ mock = new MockAdapter(axios);
+
+ mockDispatch = jest.fn().mockResolvedValue();
+ mockCommit = jest.fn();
+ });
+
+ afterEach(() => {
+ mock.restore();
+ });
+
+ describe('initialize', () => {
+ const initialData = {
+ milestonesEndpoint,
+ labelsEndpoint,
+ groupEndpoint,
+ projectEndpoint,
+ selectedAuthor: 'Mr cool',
+ selectedMilestone: 'NEXT',
+ };
+
+ it('does not dispatch', () => {
+ const result = actions.initialize(
+ {
+ state,
+ dispatch: mockDispatch,
+ commit: mockCommit,
+ },
+ initialData,
+ );
+ expect(result).toBeUndefined();
+ expect(mockDispatch).not.toHaveBeenCalled();
+ });
+
+ it(`commits the ${types.SET_SELECTED_FILTERS}`, () => {
+ actions.initialize(
+ {
+ state,
+ dispatch: mockDispatch,
+ commit: mockCommit,
+ },
+ initialData,
+ );
+ expect(mockCommit).toHaveBeenCalledWith(types.SET_SELECTED_FILTERS, initialData);
+ });
+ });
+
+ describe('setFilters', () => {
+ const nextFilters = {
+ selectedAuthor: 'Mr cool',
+ selectedMilestone: 'NEXT',
+ };
+
+ it('dispatches the root/setFilters action', () => {
+ return testAction(
+ actions.setFilters,
+ nextFilters,
+ state,
+ [
+ {
+ payload: nextFilters,
+ type: types.SET_SELECTED_FILTERS,
+ },
+ ],
+ [
+ {
+ type: 'setFilters',
+ payload: nextFilters,
+ },
+ ],
+ );
+ });
+ });
+
+ describe('setEndpoints', () => {
+ it('sets the api paths', () => {
+ return testAction(
+ actions.setEndpoints,
+ { milestonesEndpoint, labelsEndpoint, groupEndpoint, projectEndpoint },
+ state,
+ [
+ { payload: 'fake_milestones_endpoint', type: types.SET_MILESTONES_ENDPOINT },
+ { payload: 'fake_labels_endpoint', type: types.SET_LABELS_ENDPOINT },
+ { payload: 'fake_group_endpoint', type: types.SET_GROUP_ENDPOINT },
+ { payload: 'fake_project_endpoint', type: types.SET_PROJECT_ENDPOINT },
+ ],
+ [],
+ );
+ });
+ });
+
+ describe('fetchBranches', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ const url = Api.buildUrl(Api.createBranchPath).replace(
+ ':id',
+ encodeURIComponent(projectEndpoint),
+ );
+ mock.onGet(url).replyOnce(httpStatusCodes.OK, mockBranches);
+ });
+
+ it('dispatches RECEIVE_BRANCHES_SUCCESS with received data', () => {
+ return testAction(
+ actions.fetchBranches,
+ null,
+ { ...state, projectEndpoint },
+ [
+ { type: types.REQUEST_BRANCHES },
+ { type: types.RECEIVE_BRANCHES_SUCCESS, payload: mockBranches },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(data).toBe(mockBranches);
+ });
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
+ });
+
+ it('dispatches RECEIVE_BRANCHES_ERROR', () => {
+ return testAction(
+ actions.fetchBranches,
+ null,
+ state,
+ [
+ { type: types.REQUEST_BRANCHES },
+ {
+ type: types.RECEIVE_BRANCHES_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => expect(createFlash).toHaveBeenCalled());
+ });
+ });
+ });
+
+ describe('fetchAuthors', () => {
+ let restoreVersion;
+ beforeEach(() => {
+ restoreVersion = gon.api_version;
+ gon.api_version = 'v1';
+ });
+
+ afterEach(() => {
+ gon.api_version = restoreVersion;
+ });
+
+ describe('success', () => {
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers);
+ });
+
+ it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and groupEndpoint set', () => {
+ return testAction(
+ actions.fetchAuthors,
+ null,
+ { ...state, groupEndpoint },
+ [
+ { type: types.REQUEST_AUTHORS },
+ { type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
+ expect(data).toBe(filterUsers);
+ });
+ });
+
+ it('dispatches RECEIVE_AUTHORS_SUCCESS with received data and projectEndpoint set', () => {
+ return testAction(
+ actions.fetchAuthors,
+ null,
+ { ...state, projectEndpoint },
+ [
+ { type: types.REQUEST_AUTHORS },
+ { type: types.RECEIVE_AUTHORS_SUCCESS, payload: filterUsers },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
+ expect(data).toBe(filterUsers);
+ });
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
+ });
+
+ it('dispatches RECEIVE_AUTHORS_ERROR and groupEndpoint set', () => {
+ return testAction(
+ actions.fetchAuthors,
+ null,
+ { ...state, groupEndpoint },
+ [
+ { type: types.REQUEST_AUTHORS },
+ {
+ type: types.RECEIVE_AUTHORS_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => {
+ expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ it('dispatches RECEIVE_AUTHORS_ERROR and projectEndpoint set', () => {
+ return testAction(
+ actions.fetchAuthors,
+ null,
+ { ...state, projectEndpoint },
+ [
+ { type: types.REQUEST_AUTHORS },
+ {
+ type: types.RECEIVE_AUTHORS_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => {
+ expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('fetchMilestones', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ mock.onGet(milestonesEndpoint).replyOnce(httpStatusCodes.OK, filterMilestones);
+ });
+
+ it('dispatches RECEIVE_MILESTONES_SUCCESS with received data', () => {
+ return testAction(
+ actions.fetchMilestones,
+ null,
+ { ...state, milestonesEndpoint },
+ [
+ { type: types.REQUEST_MILESTONES },
+ { type: types.RECEIVE_MILESTONES_SUCCESS, payload: filterMilestones },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(data).toBe(filterMilestones);
+ });
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
+ });
+
+ it('dispatches RECEIVE_MILESTONES_ERROR', () => {
+ return testAction(
+ actions.fetchMilestones,
+ null,
+ state,
+ [
+ { type: types.REQUEST_MILESTONES },
+ {
+ type: types.RECEIVE_MILESTONES_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => expect(createFlash).toHaveBeenCalled());
+ });
+ });
+ });
+
+ describe('fetchAssignees', () => {
+ describe('success', () => {
+ let restoreVersion;
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.OK, filterUsers);
+ restoreVersion = gon.api_version;
+ gon.api_version = 'v1';
+ });
+
+ afterEach(() => {
+ gon.api_version = restoreVersion;
+ });
+
+ it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and groupEndpoint set', () => {
+ return testAction(
+ actions.fetchAssignees,
+ null,
+ { ...state, milestonesEndpoint, groupEndpoint },
+ [
+ { type: types.REQUEST_ASSIGNEES },
+ { type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
+ expect(data).toBe(filterUsers);
+ });
+ });
+
+ it('dispatches RECEIVE_ASSIGNEES_SUCCESS with received data and projectEndpoint set', () => {
+ return testAction(
+ actions.fetchAssignees,
+ null,
+ { ...state, milestonesEndpoint, projectEndpoint },
+ [
+ { type: types.REQUEST_ASSIGNEES },
+ { type: types.RECEIVE_ASSIGNEES_SUCCESS, payload: filterUsers },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
+ expect(data).toBe(filterUsers);
+ });
+ });
+ });
+
+ describe('error', () => {
+ let restoreVersion;
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
+ restoreVersion = gon.api_version;
+ gon.api_version = 'v1';
+ });
+
+ afterEach(() => {
+ gon.api_version = restoreVersion;
+ });
+
+ it('dispatches RECEIVE_ASSIGNEES_ERROR and groupEndpoint set', () => {
+ return testAction(
+ actions.fetchAssignees,
+ null,
+ { ...state, groupEndpoint },
+ [
+ { type: types.REQUEST_ASSIGNEES },
+ {
+ type: types.RECEIVE_ASSIGNEES_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => {
+ expect(mock.history.get[0].url).toBe('/api/v1/groups/fake_group_endpoint/members');
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+
+ it('dispatches RECEIVE_ASSIGNEES_ERROR and projectEndpoint set', () => {
+ return testAction(
+ actions.fetchAssignees,
+ null,
+ { ...state, projectEndpoint },
+ [
+ { type: types.REQUEST_ASSIGNEES },
+ {
+ type: types.RECEIVE_ASSIGNEES_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => {
+ expect(mock.history.get[0].url).toBe('/api/v1/projects/fake_project_endpoint/users');
+ expect(createFlash).toHaveBeenCalled();
+ });
+ });
+ });
+ });
+
+ describe('fetchLabels', () => {
+ describe('success', () => {
+ beforeEach(() => {
+ mock.onGet(labelsEndpoint).replyOnce(httpStatusCodes.OK, filterLabels);
+ });
+
+ it('dispatches RECEIVE_LABELS_SUCCESS with received data', () => {
+ return testAction(
+ actions.fetchLabels,
+ null,
+ { ...state, labelsEndpoint },
+ [
+ { type: types.REQUEST_LABELS },
+ { type: types.RECEIVE_LABELS_SUCCESS, payload: filterLabels },
+ ],
+ [],
+ ).then(({ data }) => {
+ expect(data).toBe(filterLabels);
+ });
+ });
+ });
+
+ describe('error', () => {
+ beforeEach(() => {
+ mock.onAny().replyOnce(httpStatusCodes.SERVICE_UNAVAILABLE);
+ });
+
+ it('dispatches RECEIVE_LABELS_ERROR', () => {
+ return testAction(
+ actions.fetchLabels,
+ null,
+ state,
+ [
+ { type: types.REQUEST_LABELS },
+ {
+ type: types.RECEIVE_LABELS_ERROR,
+ payload: httpStatusCodes.SERVICE_UNAVAILABLE,
+ },
+ ],
+ [],
+ ).then(() => expect(createFlash).toHaveBeenCalled());
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js
new file mode 100644
index 00000000000..6afac9f752a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data.js
@@ -0,0 +1,50 @@
+export const filterMilestones = [
+ { id: 1, title: 'None', name: 'Any' },
+ { id: 101, title: 'Any', name: 'None' },
+ { id: 1001, title: 'v1.0', name: 'v1.0' },
+ { id: 10101, title: 'v0.0', name: 'v0.0' },
+];
+
+export const filterUsers = [
+ {
+ id: 31,
+ name: 'VSM User2',
+ username: 'vsm-user-2-1589776313',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/762398957a8c6e04eed16da88098899d?s=80\u0026d=identicon',
+ web_url: 'http://127.0.0.1:3001/vsm-user-2-1589776313',
+ access_level: 30,
+ expires_at: null,
+ },
+ {
+ id: 32,
+ name: 'VSM User3',
+ username: 'vsm-user-3-1589776313',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/f78932237e8a5c5376b65a709824802f?s=80\u0026d=identicon',
+ web_url: 'http://127.0.0.1:3001/vsm-user-3-1589776313',
+ access_level: 30,
+ expires_at: null,
+ },
+ {
+ id: 33,
+ name: 'VSM User4',
+ username: 'vsm-user-4-1589776313',
+ state: 'active',
+ avatar_url:
+ 'https://www.gravatar.com/avatar/ab506dc600d1a941e4d77d5ceeeba73f?s=80\u0026d=identicon',
+ web_url: 'http://127.0.0.1:3001/vsm-user-4-1589776313',
+ access_level: 30,
+ expires_at: null,
+ },
+];
+
+export const filterLabels = [
+ { id: 194, title: 'Afterfunc-Phureforge-781', color: '#990000', text_color: '#FFFFFF' },
+ { id: 10, title: 'Afternix', color: '#16ecf2', text_color: '#FFFFFF' },
+ { id: 176, title: 'Panasync-Pens-266', color: '#990000', text_color: '#FFFFFF' },
+ { id: 79, title: 'Passat', color: '#f1a3d4', text_color: '#333333' },
+ { id: 197, title: 'Phast-Onesync-395', color: '#990000', text_color: '#FFFFFF' },
+];
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js
new file mode 100644
index 00000000000..263a4ee178f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/mutations_spec.js
@@ -0,0 +1,116 @@
+import { get } from 'lodash';
+import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data';
+import initialState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state';
+import mutations from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutations';
+import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types';
+import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils';
+import { filterMilestones, filterUsers, filterLabels } from './mock_data';
+
+let state = null;
+
+const branches = mockBranches.map(convertObjectPropsToCamelCase);
+const milestones = filterMilestones.map(convertObjectPropsToCamelCase);
+const users = filterUsers.map(convertObjectPropsToCamelCase);
+const labels = filterLabels.map(convertObjectPropsToCamelCase);
+
+const filterValue = { value: 'foo' };
+
+describe('Filters mutations', () => {
+ const errorCode = 500;
+ beforeEach(() => {
+ state = initialState();
+ });
+
+ afterEach(() => {
+ state = null;
+ });
+
+ it.each`
+ mutation | stateKey | value
+ ${types.SET_MILESTONES_ENDPOINT} | ${'milestonesEndpoint'} | ${'new-milestone-endpoint'}
+ ${types.SET_LABELS_ENDPOINT} | ${'labelsEndpoint'} | ${'new-label-endpoint'}
+ ${types.SET_GROUP_ENDPOINT} | ${'groupEndpoint'} | ${'new-group-endpoint'}
+ `('$mutation will set $stateKey=$value', ({ mutation, stateKey, value }) => {
+ mutations[mutation](state, value);
+
+ expect(state[stateKey]).toEqual(value);
+ });
+
+ it.each`
+ mutation | stateKey | filterName | value
+ ${types.SET_SELECTED_FILTERS} | ${'branches.source.selected'} | ${'selectedSourceBranch'} | ${null}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.source.selected'} | ${'selectedSourceBranch'} | ${filterValue}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.source.selectedList'} | ${'selectedSourceBranchList'} | ${[]}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.source.selectedList'} | ${'selectedSourceBranchList'} | ${[filterValue]}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.target.selected'} | ${'selectedTargetBranch'} | ${null}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.target.selected'} | ${'selectedTargetBranch'} | ${filterValue}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.target.selectedList'} | ${'selectedTargetBranchList'} | ${[]}
+ ${types.SET_SELECTED_FILTERS} | ${'branches.target.selectedList'} | ${'selectedTargetBranchList'} | ${[filterValue]}
+ ${types.SET_SELECTED_FILTERS} | ${'authors.selected'} | ${'selectedAuthor'} | ${null}
+ ${types.SET_SELECTED_FILTERS} | ${'authors.selected'} | ${'selectedAuthor'} | ${filterValue}
+ ${types.SET_SELECTED_FILTERS} | ${'authors.selectedList'} | ${'selectedAuthorList'} | ${[]}
+ ${types.SET_SELECTED_FILTERS} | ${'authors.selectedList'} | ${'selectedAuthorList'} | ${[filterValue]}
+ ${types.SET_SELECTED_FILTERS} | ${'milestones.selected'} | ${'selectedMilestone'} | ${null}
+ ${types.SET_SELECTED_FILTERS} | ${'milestones.selected'} | ${'selectedMilestone'} | ${filterValue}
+ ${types.SET_SELECTED_FILTERS} | ${'milestones.selectedList'} | ${'selectedMilestoneList'} | ${[]}
+ ${types.SET_SELECTED_FILTERS} | ${'milestones.selectedList'} | ${'selectedMilestoneList'} | ${[filterValue]}
+ ${types.SET_SELECTED_FILTERS} | ${'assignees.selected'} | ${'selectedAssignee'} | ${null}
+ ${types.SET_SELECTED_FILTERS} | ${'assignees.selected'} | ${'selectedAssignee'} | ${filterValue}
+ ${types.SET_SELECTED_FILTERS} | ${'assignees.selectedList'} | ${'selectedAssigneeList'} | ${[]}
+ ${types.SET_SELECTED_FILTERS} | ${'assignees.selectedList'} | ${'selectedAssigneeList'} | ${[filterValue]}
+ ${types.SET_SELECTED_FILTERS} | ${'labels.selected'} | ${'selectedLabel'} | ${null}
+ ${types.SET_SELECTED_FILTERS} | ${'labels.selected'} | ${'selectedLabel'} | ${filterValue}
+ ${types.SET_SELECTED_FILTERS} | ${'labels.selectedList'} | ${'selectedLabelList'} | ${[]}
+ ${types.SET_SELECTED_FILTERS} | ${'labels.selectedList'} | ${'selectedLabelList'} | ${[filterValue]}
+ `(
+ '$mutation will set $stateKey with a given value',
+ ({ mutation, stateKey, filterName, value }) => {
+ mutations[mutation](state, { [filterName]: value });
+
+ expect(get(state, stateKey)).toEqual(value);
+ },
+ );
+
+ it.each`
+ mutation | rootKey | stateKey | value
+ ${types.REQUEST_BRANCHES} | ${'branches'} | ${'isLoading'} | ${true}
+ ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'data'} | ${branches}
+ ${types.RECEIVE_BRANCHES_SUCCESS} | ${'branches'} | ${'errorCode'} | ${null}
+ ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'data'} | ${[]}
+ ${types.RECEIVE_BRANCHES_ERROR} | ${'branches'} | ${'errorCode'} | ${errorCode}
+ ${types.REQUEST_MILESTONES} | ${'milestones'} | ${'isLoading'} | ${true}
+ ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'data'} | ${milestones}
+ ${types.RECEIVE_MILESTONES_SUCCESS} | ${'milestones'} | ${'errorCode'} | ${null}
+ ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'data'} | ${[]}
+ ${types.RECEIVE_MILESTONES_ERROR} | ${'milestones'} | ${'errorCode'} | ${errorCode}
+ ${types.REQUEST_AUTHORS} | ${'authors'} | ${'isLoading'} | ${true}
+ ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'data'} | ${users}
+ ${types.RECEIVE_AUTHORS_SUCCESS} | ${'authors'} | ${'errorCode'} | ${null}
+ ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'data'} | ${[]}
+ ${types.RECEIVE_AUTHORS_ERROR} | ${'authors'} | ${'errorCode'} | ${errorCode}
+ ${types.REQUEST_LABELS} | ${'labels'} | ${'isLoading'} | ${true}
+ ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'data'} | ${labels}
+ ${types.RECEIVE_LABELS_SUCCESS} | ${'labels'} | ${'errorCode'} | ${null}
+ ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'data'} | ${[]}
+ ${types.RECEIVE_LABELS_ERROR} | ${'labels'} | ${'errorCode'} | ${errorCode}
+ ${types.REQUEST_ASSIGNEES} | ${'assignees'} | ${'isLoading'} | ${true}
+ ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'data'} | ${users}
+ ${types.RECEIVE_ASSIGNEES_SUCCESS} | ${'assignees'} | ${'errorCode'} | ${null}
+ ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'isLoading'} | ${false}
+ ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'data'} | ${[]}
+ ${types.RECEIVE_ASSIGNEES_ERROR} | ${'assignees'} | ${'errorCode'} | ${errorCode}
+ `('$mutation will set $stateKey with a given value', ({ mutation, rootKey, stateKey, value }) => {
+ mutations[mutation](state, value);
+
+ expect(state[rootKey][stateKey]).toEqual(value);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js
new file mode 100644
index 00000000000..1b7c80a5252
--- /dev/null
+++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/test_helper.js
@@ -0,0 +1,11 @@
+export function getFilterParams(tokens, options = {}) {
+ const { key = 'value', operator = '=', prop = 'title' } = options;
+ return tokens.map(token => {
+ return { [key]: token[prop], operator };
+ });
+}
+
+export function getFilterValues(tokens, options = {}) {
+ const { prop = 'title' } = options;
+ return tokens.map(token => token[prop]);
+}
diff --git a/spec/frontend/vue_shared/components/local_storage_sync_spec.js b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
index 5470171a21e..efa9b5796fb 100644
--- a/spec/frontend/vue_shared/components/local_storage_sync_spec.js
+++ b/spec/frontend/vue_shared/components/local_storage_sync_spec.js
@@ -12,7 +12,9 @@ describe('Local Storage Sync', () => {
};
afterEach(() => {
- wrapper.destroy();
+ if (wrapper) {
+ wrapper.destroy();
+ }
wrapper = null;
localStorage.clear();
});
@@ -45,23 +47,23 @@ describe('Local Storage Sync', () => {
expect(wrapper.emitted('input')).toBeFalsy();
});
- it('saves updated value to localStorage', () => {
- createComponent({
- props: {
- storageKey,
- value: 'ascending',
- },
- });
-
- const newValue = 'descending';
- wrapper.setProps({
- value: newValue,
- });
-
- return wrapper.vm.$nextTick().then(() => {
- expect(localStorage.getItem(storageKey)).toBe(newValue);
- });
- });
+ it.each('foo', 3, true, ['foo', 'bar'], { foo: 'bar' })(
+ 'saves updated value to localStorage',
+ newValue => {
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ },
+ });
+
+ wrapper.setProps({ value: newValue });
+
+ return wrapper.vm.$nextTick().then(() => {
+ expect(localStorage.getItem(storageKey)).toBe(String(newValue));
+ });
+ },
+ );
it('does not save default value', () => {
const value = 'ascending';
@@ -124,5 +126,117 @@ describe('Local Storage Sync', () => {
expect(localStorage.getItem(storageKey)).toBe(newValue);
});
});
+
+ it('persists the value by default', async () => {
+ const persistedValue = 'persisted';
+
+ createComponent({
+ props: {
+ storageKey,
+ },
+ });
+
+ wrapper.setProps({ value: persistedValue });
+ await wrapper.vm.$nextTick();
+ expect(localStorage.getItem(storageKey)).toBe(persistedValue);
+ });
+
+ it('does not save a value if persist is set to false', async () => {
+ const notPersistedValue = 'notPersisted';
+
+ createComponent({
+ props: {
+ storageKey,
+ },
+ });
+
+ wrapper.setProps({ persist: false, value: notPersistedValue });
+ await wrapper.vm.$nextTick();
+ expect(localStorage.getItem(storageKey)).not.toBe(notPersistedValue);
+ });
+ });
+
+ describe('with "asJson" prop set to "true"', () => {
+ const storageKey = 'testStorageKey';
+
+ describe.each`
+ value | serializedValue
+ ${null} | ${'null'}
+ ${''} | ${'""'}
+ ${true} | ${'true'}
+ ${false} | ${'false'}
+ ${42} | ${'42'}
+ ${'42'} | ${'"42"'}
+ ${'{ foo: '} | ${'"{ foo: "'}
+ ${['test']} | ${'["test"]'}
+ ${{ foo: 'bar' }} | ${'{"foo":"bar"}'}
+ `('given $value', ({ value, serializedValue }) => {
+ describe('is a new value', () => {
+ beforeEach(() => {
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+
+ wrapper.setProps({ value });
+
+ return wrapper.vm.$nextTick();
+ });
+
+ it('serializes the value correctly to localStorage', () => {
+ expect(localStorage.getItem(storageKey)).toBe(serializedValue);
+ });
+ });
+
+ describe('is already stored', () => {
+ beforeEach(() => {
+ localStorage.setItem(storageKey, serializedValue);
+
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+ });
+
+ it('emits an input event with the deserialized value', () => {
+ expect(wrapper.emitted('input')).toEqual([[value]]);
+ });
+ });
+ });
+
+ describe('with bad JSON in storage', () => {
+ const badJSON = '{ badJSON';
+
+ beforeEach(() => {
+ jest.spyOn(console, 'warn').mockImplementation();
+ localStorage.setItem(storageKey, badJSON);
+
+ createComponent({
+ props: {
+ storageKey,
+ value: 'initial',
+ asJson: true,
+ },
+ });
+ });
+
+ it('should console warn', () => {
+ // eslint-disable-next-line no-console
+ expect(console.warn).toHaveBeenCalledWith(
+ `[gitlab] Failed to deserialize value from localStorage (key=${storageKey})`,
+ badJSON,
+ );
+ });
+
+ it('should not emit an input event', () => {
+ expect(wrapper.emitted('input')).toBeUndefined();
+ });
+ });
});
});
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 cdd7a3ccaf0..b8a9143bc79 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
@@ -10,6 +10,7 @@ exports[`Suggestion Diff component matches snapshot 1`] = `
helppagepath="path_to_docs"
isapplyingbatch="true"
isbatched="true"
+ suggestionscount="0"
/>
<table
diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js
index 3da0a35f05a..a2ce6f40193 100644
--- a/spec/frontend/vue_shared/components/markdown/field_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/field_spec.js
@@ -2,11 +2,13 @@ import { mount } from '@vue/test-utils';
import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants';
import AxiosMockAdapter from 'axios-mock-adapter';
import $ from 'jquery';
-import fieldComponent from '~/vue_shared/components/markdown/field.vue';
+import MarkdownField from '~/vue_shared/components/markdown/field.vue';
import axios from '~/lib/utils/axios_utils';
const markdownPreviewPath = `${TEST_HOST}/preview`;
const markdownDocsPath = `${TEST_HOST}/docs`;
+const textareaValue = 'testing\n123';
+const uploadsPath = 'test/uploads';
function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
expect(writeLink.element.parentNode.classList.contains('active')).toBe(isWrite);
@@ -14,66 +16,81 @@ function assertMarkdownTabs(isWrite, writeLink, previewLink, wrapper) {
expect(wrapper.find('.md-preview-holder').element.style.display).toBe(isWrite ? 'none' : '');
}
-function createComponent() {
- const wrapper = mount(fieldComponent, {
- propsData: {
- markdownDocsPath,
- markdownPreviewPath,
- isSubmitting: false,
- },
- slots: {
- textarea: '<textarea>testing\n123</textarea>',
- },
- template: `
- <field-component
- markdown-preview-path="${markdownPreviewPath}"
- markdown-docs-path="${markdownDocsPath}"
- :isSubmitting="false"
- >
- <textarea
- slot="textarea"
- v-model="text">
- <slot>this is a test</slot>
- </textarea>
- </field-component>
- `,
- });
- return wrapper;
-}
-
-const getPreviewLink = wrapper => wrapper.find('.nav-links .js-preview-link');
-const getWriteLink = wrapper => wrapper.find('.nav-links .js-write-link');
-const getMarkdownButton = wrapper => wrapper.find('.js-md');
-const getAllMarkdownButtons = wrapper => wrapper.findAll('.js-md');
-const getVideo = wrapper => wrapper.find('video');
-
describe('Markdown field component', () => {
let axiosMock;
+ let subject;
beforeEach(() => {
axiosMock = new AxiosMockAdapter(axios);
+ // window.uploads_path is needed for dropzone to initialize
+ window.uploads_path = uploadsPath;
});
afterEach(() => {
+ subject.destroy();
+ subject = null;
axiosMock.restore();
});
+ function createSubject() {
+ // We actually mount a wrapper component so that we can force Vue to rerender classes in order to test a regression
+ // caused by mixing Vanilla JS and Vue.
+ subject = mount(
+ {
+ components: {
+ MarkdownField,
+ },
+ props: {
+ wrapperClasses: {
+ type: String,
+ required: false,
+ default: '',
+ },
+ },
+ template: `
+<markdown-field :class="wrapperClasses" v-bind="$attrs">
+ <template #textarea>
+ <textarea class="js-gfm-input" :value="$attrs.textareaValue"></textarea>
+ </template>
+</markdown-field>`,
+ },
+ {
+ propsData: {
+ markdownDocsPath,
+ markdownPreviewPath,
+ isSubmitting: false,
+ textareaValue,
+ },
+ },
+ );
+ }
+
+ const getPreviewLink = () => subject.find('.nav-links .js-preview-link');
+ const getWriteLink = () => subject.find('.nav-links .js-write-link');
+ const getMarkdownButton = () => subject.find('.js-md');
+ const getAllMarkdownButtons = () => subject.findAll('.js-md');
+ const getVideo = () => subject.find('video');
+ const getAttachButton = () => subject.find('.button-attach-file');
+ const clickAttachButton = () => getAttachButton().trigger('click');
+ const findDropzone = () => subject.find('.div-dropzone');
+
describe('mounted', () => {
- let wrapper;
const previewHTML = `
<p>markdown preview</p>
<video src="${FIXTURES_PATH}/static/mock-video.mp4" muted="muted"></video>
`;
let previewLink;
let writeLink;
+ let dropzoneSpy;
- afterEach(() => {
- wrapper.destroy();
+ beforeEach(() => {
+ dropzoneSpy = jest.fn();
+ createSubject();
+ findDropzone().element.addEventListener('click', dropzoneSpy);
});
it('renders textarea inside backdrop', () => {
- wrapper = createComponent();
- expect(wrapper.find('.zen-backdrop textarea').element).not.toBeNull();
+ expect(subject.find('.zen-backdrop textarea').element).not.toBeNull();
});
describe('markdown preview', () => {
@@ -82,44 +99,40 @@ describe('Markdown field component', () => {
});
it('sets preview link as active', () => {
- wrapper = createComponent();
- previewLink = getPreviewLink(wrapper);
+ previewLink = getPreviewLink();
previewLink.trigger('click');
- return wrapper.vm.$nextTick().then(() => {
+ return subject.vm.$nextTick().then(() => {
expect(previewLink.element.parentNode.classList.contains('active')).toBeTruthy();
});
});
it('shows preview loading text', () => {
- wrapper = createComponent();
- previewLink = getPreviewLink(wrapper);
+ previewLink = getPreviewLink();
previewLink.trigger('click');
- return wrapper.vm.$nextTick(() => {
- expect(wrapper.find('.md-preview-holder').element.textContent.trim()).toContain(
+ return subject.vm.$nextTick(() => {
+ expect(subject.find('.md-preview-holder').element.textContent.trim()).toContain(
'Loading…',
);
});
});
it('renders markdown preview and GFM', () => {
- wrapper = createComponent();
const renderGFMSpy = jest.spyOn($.fn, 'renderGFM');
- previewLink = getPreviewLink(wrapper);
+ previewLink = getPreviewLink();
previewLink.trigger('click');
return axios.waitFor(markdownPreviewPath).then(() => {
- expect(wrapper.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
+ expect(subject.find('.md-preview-holder').element.innerHTML).toContain(previewHTML);
expect(renderGFMSpy).toHaveBeenCalled();
});
});
it('calls video.pause() on comment input when isSubmitting is changed to true', () => {
- wrapper = createComponent();
- previewLink = getPreviewLink(wrapper);
+ previewLink = getPreviewLink();
previewLink.trigger('click');
let callPause;
@@ -127,79 +140,107 @@ describe('Markdown field component', () => {
return axios
.waitFor(markdownPreviewPath)
.then(() => {
- const video = getVideo(wrapper);
+ const video = getVideo();
callPause = jest.spyOn(video.element, 'pause').mockImplementation(() => true);
- wrapper.setProps({
- isSubmitting: true,
- markdownPreviewPath,
- markdownDocsPath,
- });
+ subject.setProps({ isSubmitting: true });
- return wrapper.vm.$nextTick();
+ return subject.vm.$nextTick();
})
.then(() => {
expect(callPause).toHaveBeenCalled();
});
});
- it('clicking already active write or preview link does nothing', () => {
- wrapper = createComponent();
- writeLink = getWriteLink(wrapper);
- previewLink = getPreviewLink(wrapper);
+ it('clicking already active write or preview link does nothing', async () => {
+ writeLink = getWriteLink();
+ previewLink = getPreviewLink();
+
+ writeLink.trigger('click');
+ await subject.vm.$nextTick();
+ assertMarkdownTabs(true, writeLink, previewLink, subject);
writeLink.trigger('click');
- return wrapper.vm
- .$nextTick()
- .then(() => assertMarkdownTabs(true, writeLink, previewLink, wrapper))
- .then(() => writeLink.trigger('click'))
- .then(() => wrapper.vm.$nextTick())
- .then(() => assertMarkdownTabs(true, writeLink, previewLink, wrapper))
- .then(() => previewLink.trigger('click'))
- .then(() => wrapper.vm.$nextTick())
- .then(() => assertMarkdownTabs(false, writeLink, previewLink, wrapper))
- .then(() => previewLink.trigger('click'))
- .then(() => wrapper.vm.$nextTick())
- .then(() => assertMarkdownTabs(false, writeLink, previewLink, wrapper));
+ await subject.vm.$nextTick();
+
+ assertMarkdownTabs(true, writeLink, previewLink, subject);
+ previewLink.trigger('click');
+ await subject.vm.$nextTick();
+
+ assertMarkdownTabs(false, writeLink, previewLink, subject);
+ previewLink.trigger('click');
+ await subject.vm.$nextTick();
+
+ assertMarkdownTabs(false, writeLink, previewLink, subject);
});
});
describe('markdown buttons', () => {
it('converts single words', () => {
- wrapper = createComponent();
- const textarea = wrapper.find('textarea').element;
+ const textarea = subject.find('textarea').element;
textarea.setSelectionRange(0, 7);
- const markdownButton = getMarkdownButton(wrapper);
+ const markdownButton = getMarkdownButton();
markdownButton.trigger('click');
- return wrapper.vm.$nextTick(() => {
+ return subject.vm.$nextTick(() => {
expect(textarea.value).toContain('**testing**');
});
});
it('converts a line', () => {
- wrapper = createComponent();
- const textarea = wrapper.find('textarea').element;
+ const textarea = subject.find('textarea').element;
textarea.setSelectionRange(0, 0);
- const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5];
+ const markdownButton = getAllMarkdownButtons().wrappers[5];
markdownButton.trigger('click');
- return wrapper.vm.$nextTick(() => {
+ return subject.vm.$nextTick(() => {
expect(textarea.value).toContain('- testing');
});
});
it('converts multiple lines', () => {
- wrapper = createComponent();
- const textarea = wrapper.find('textarea').element;
+ const textarea = subject.find('textarea').element;
textarea.setSelectionRange(0, 50);
- const markdownButton = getAllMarkdownButtons(wrapper).wrappers[5];
+ const markdownButton = getAllMarkdownButtons().wrappers[5];
markdownButton.trigger('click');
- return wrapper.vm.$nextTick(() => {
+ return subject.vm.$nextTick(() => {
expect(textarea.value).toContain('- testing\n- 123');
});
});
});
+
+ it('should render attach a file button', () => {
+ expect(getAttachButton().text()).toBe('Attach a file');
+ });
+
+ it('should trigger dropzone when attach button is clicked', () => {
+ expect(dropzoneSpy).not.toHaveBeenCalled();
+
+ clickAttachButton();
+
+ expect(dropzoneSpy).toHaveBeenCalled();
+ });
+
+ describe('when textarea has changed', () => {
+ beforeEach(async () => {
+ // Do something to trigger rerendering the class
+ subject.setProps({ wrapperClasses: 'foo' });
+
+ await subject.vm.$nextTick();
+ });
+
+ it('should have rerendered classes and kept gfm-form', () => {
+ expect(subject.classes()).toEqual(expect.arrayContaining(['gfm-form', 'foo']));
+ });
+
+ it('should trigger dropzone when attach button is clicked', () => {
+ expect(dropzoneSpy).not.toHaveBeenCalled();
+
+ clickAttachButton();
+
+ expect(dropzoneSpy).toHaveBeenCalled();
+ });
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
index a521668b15c..b19e74b5b11 100644
--- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
+++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js
@@ -57,7 +57,9 @@ describe('Suggestion Diff component', () => {
});
it('renders apply suggestion and add to batch buttons', () => {
- createComponent();
+ createComponent({
+ suggestionsCount: 2,
+ });
const applyBtn = findApplyButton();
const addToBatchBtn = findAddToBatchButton();
@@ -104,7 +106,9 @@ describe('Suggestion Diff component', () => {
describe('when add to batch is clicked', () => {
it('emits addToBatch', () => {
- createComponent();
+ createComponent({
+ suggestionsCount: 2,
+ });
findAddToBatchButton().vm.$emit('click');
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js
new file mode 100644
index 00000000000..58cb8ef61d1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/access_request_action_buttons_spec.js
@@ -0,0 +1,108 @@
+import { shallowMount } from '@vue/test-utils';
+import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
+import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
+import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue';
+import { accessRequest as member } from '../mock_data';
+
+describe('AccessRequestActionButtons', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(AccessRequestActionButtons, {
+ propsData: {
+ member,
+ isCurrentUser: true,
+ ...propsData,
+ },
+ });
+ };
+
+ const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
+ const findApproveButton = () => wrapper.find(ApproveAccessRequestButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when user has `canRemove` permissions', () => {
+ beforeEach(() => {
+ createComponent({
+ permissions: {
+ canRemove: true,
+ },
+ });
+ });
+
+ it('renders remove member button', () => {
+ expect(findRemoveMemberButton().exists()).toBe(true);
+ });
+
+ it('sets props correctly', () => {
+ expect(findRemoveMemberButton().props()).toMatchObject({
+ memberId: member.id,
+ title: 'Deny access',
+ isAccessRequest: true,
+ icon: 'close',
+ });
+ });
+
+ describe('when member is the current user', () => {
+ it('sets `message` prop correctly', () => {
+ expect(findRemoveMemberButton().props('message')).toBe(
+ `Are you sure you want to withdraw your access request for "${member.source.name}"`,
+ );
+ });
+ });
+
+ describe('when member is not the current user', () => {
+ it('sets `message` prop correctly', () => {
+ createComponent({
+ isCurrentUser: false,
+ permissions: {
+ canRemove: true,
+ },
+ });
+
+ expect(findRemoveMemberButton().props('message')).toBe(
+ `Are you sure you want to deny ${member.user.name}'s request to join "${member.source.name}"`,
+ );
+ });
+ });
+ });
+
+ describe('when user does not have `canRemove` permissions', () => {
+ it('does not render remove member button', () => {
+ createComponent({
+ permissions: {
+ canRemove: false,
+ },
+ });
+
+ expect(findRemoveMemberButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when user has `canUpdate` permissions', () => {
+ it('renders the approve button', () => {
+ createComponent({
+ permissions: {
+ canUpdate: true,
+ },
+ });
+
+ expect(findApproveButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when user does not have `canUpdate` permissions', () => {
+ it('does not render the approve button', () => {
+ createComponent({
+ permissions: {
+ canUpdate: false,
+ },
+ });
+
+ expect(findApproveButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js
new file mode 100644
index 00000000000..93edaaa400d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/approve_access_request_button_spec.js
@@ -0,0 +1,74 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlButton, GlForm } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ApproveAccessRequestButton from '~/vue_shared/components/members/action_buttons/approve_access_request_button.vue';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ApproveAccessRequestButton', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ memberPath: '/groups/foo-bar/-/group_members/:id',
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = (propsData = {}, state) => {
+ wrapper = shallowMount(ApproveAccessRequestButton, {
+ localVue,
+ store: createStore(state),
+ propsData: {
+ memberId: 1,
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const findForm = () => wrapper.find(GlForm);
+ const findButton = () => findForm().find(GlButton);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays a tooltip', () => {
+ const button = findButton();
+
+ expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
+ expect(button.attributes('title')).toBe('Grant access');
+ });
+
+ it('sets `aria-label` attribute', () => {
+ expect(findButton().attributes('aria-label')).toBe('Grant access');
+ });
+
+ it('submits the form when button is clicked', () => {
+ expect(findButton().attributes('type')).toBe('submit');
+ });
+
+ it('displays form with correct action and inputs', () => {
+ const form = findForm();
+
+ expect(form.attributes('action')).toBe(
+ '/groups/foo-bar/-/group_members/1/approve_access_request',
+ );
+ expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js
new file mode 100644
index 00000000000..1374cdc6aef
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/invite_action_buttons_spec.js
@@ -0,0 +1,85 @@
+import { shallowMount } from '@vue/test-utils';
+import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue';
+import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
+import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue';
+import { invite as member } from '../mock_data';
+
+describe('InviteActionButtons', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(InviteActionButtons, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
+ const findResendInviteButton = () => wrapper.find(ResendInviteButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when user has `canRemove` permissions', () => {
+ beforeEach(() => {
+ createComponent({
+ permissions: {
+ canRemove: true,
+ },
+ });
+ });
+
+ it('renders remove member button', () => {
+ expect(findRemoveMemberButton().exists()).toBe(true);
+ });
+
+ it('sets props correctly', () => {
+ expect(findRemoveMemberButton().props()).toEqual({
+ memberId: member.id,
+ message: `Are you sure you want to revoke the invitation for ${member.invite.email} to join "${member.source.name}"`,
+ title: 'Revoke invite',
+ isAccessRequest: false,
+ icon: 'remove',
+ });
+ });
+ });
+
+ describe('when user does not have `canRemove` permissions', () => {
+ it('does not render remove member button', () => {
+ createComponent({
+ permissions: {
+ canRemove: false,
+ },
+ });
+
+ expect(findRemoveMemberButton().exists()).toBe(false);
+ });
+ });
+
+ describe('when user has `canResend` permissions', () => {
+ it('renders resend invite button', () => {
+ createComponent({
+ permissions: {
+ canResend: true,
+ },
+ });
+
+ expect(findResendInviteButton().exists()).toBe(true);
+ });
+ });
+
+ describe('when user does not have `canResend` permissions', () => {
+ it('does not render resend invite button', () => {
+ createComponent({
+ permissions: {
+ canResend: false,
+ },
+ });
+
+ expect(findResendInviteButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js
new file mode 100644
index 00000000000..00896b23b95
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/leave_button_spec.js
@@ -0,0 +1,59 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlButton } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue';
+import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue';
+import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants';
+import { member } from '../mock_data';
+
+describe('LeaveButton', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(LeaveButton, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ GlModal: createMockDirective(),
+ },
+ });
+ };
+
+ const findButton = () => wrapper.find(GlButton);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays a tooltip', () => {
+ const button = findButton();
+
+ expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
+ expect(button.attributes('title')).toBe('Leave');
+ });
+
+ it('sets `aria-label` attribute', () => {
+ expect(findButton().attributes('aria-label')).toBe('Leave');
+ });
+
+ it('renders leave modal', () => {
+ const leaveModal = wrapper.find(LeaveModal);
+
+ expect(leaveModal.exists()).toBe(true);
+ expect(leaveModal.props('member')).toEqual(member);
+ });
+
+ it('triggers leave modal', () => {
+ const binding = getBinding(findButton().element, 'gl-modal');
+
+ expect(binding).not.toBeUndefined();
+ expect(binding.value).toBe(LEAVE_MODAL_ID);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js
new file mode 100644
index 00000000000..84fe1c51773
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/remove_group_link_button_spec.js
@@ -0,0 +1,64 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import RemoveGroupLinkButton from '~/vue_shared/components/members/action_buttons/remove_group_link_button.vue';
+import { group } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('RemoveGroupLinkButton', () => {
+ let wrapper;
+
+ const actions = {
+ showRemoveGroupLinkModal: jest.fn(),
+ };
+
+ const createStore = () => {
+ return new Vuex.Store({
+ actions,
+ });
+ };
+
+ const createComponent = () => {
+ wrapper = mount(RemoveGroupLinkButton, {
+ localVue,
+ store: createStore(),
+ propsData: {
+ groupLink: group,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const findButton = () => wrapper.find(GlButton);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('displays a tooltip', () => {
+ const button = findButton();
+
+ expect(getBinding(button.element, 'gl-tooltip')).not.toBeUndefined();
+ expect(button.attributes('title')).toBe('Remove group');
+ });
+
+ it('sets `aria-label` attribute', () => {
+ expect(findButton().attributes('aria-label')).toBe('Remove group');
+ });
+
+ it('calls Vuex action to open remove group link modal when clicked', () => {
+ findButton().trigger('click');
+
+ expect(actions.showRemoveGroupLinkModal).toHaveBeenCalledWith(expect.any(Object), group);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js
new file mode 100644
index 00000000000..7aa30494234
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/remove_member_button_spec.js
@@ -0,0 +1,66 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('RemoveMemberButton', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ memberPath: '/groups/foo-bar/-/group_members/:id',
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = (propsData = {}, state) => {
+ wrapper = shallowMount(RemoveMemberButton, {
+ localVue,
+ store: createStore(state),
+ propsData: {
+ memberId: 1,
+ message: 'Are you sure you want to remove John Smith?',
+ title: 'Remove member',
+ isAccessRequest: true,
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('sets attributes on button', () => {
+ createComponent();
+
+ expect(wrapper.attributes()).toMatchObject({
+ 'data-member-path': '/groups/foo-bar/-/group_members/1',
+ 'data-message': 'Are you sure you want to remove John Smith?',
+ 'data-is-access-request': 'true',
+ 'aria-label': 'Remove member',
+ title: 'Remove member',
+ icon: 'remove',
+ });
+ });
+
+ it('displays `title` prop as a tooltip', () => {
+ createComponent();
+
+ expect(getBinding(wrapper.element, 'gl-tooltip')).not.toBeUndefined();
+ });
+
+ it('has CSS class used by `remove_member_modal.vue`', () => {
+ createComponent();
+
+ expect(wrapper.classes()).toContain('js-remove-member-button');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js
new file mode 100644
index 00000000000..859fdd01043
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/resend_invite_button_spec.js
@@ -0,0 +1,66 @@
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { GlButton } from '@gitlab/ui';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ResendInviteButton from '~/vue_shared/components/members/action_buttons/resend_invite_button.vue';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('ResendInviteButton', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ memberPath: '/groups/foo-bar/-/group_members/:id',
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = (propsData = {}, state) => {
+ wrapper = shallowMount(ResendInviteButton, {
+ localVue,
+ store: createStore(state),
+ propsData: {
+ memberId: 1,
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const findForm = () => wrapper.find('form');
+ const findButton = () => findForm().find(GlButton);
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('displays a tooltip', () => {
+ expect(getBinding(findButton().element, 'gl-tooltip')).not.toBeUndefined();
+ expect(findButton().attributes('title')).toBe('Resend invite');
+ });
+
+ it('submits the form when button is clicked', () => {
+ expect(findButton().attributes('type')).toBe('submit');
+ });
+
+ it('displays form with correct action and inputs', () => {
+ expect(findForm().attributes('action')).toBe('/groups/foo-bar/-/group_members/1/resend_invite');
+ expect(
+ findForm()
+ .find('input[name="authenticity_token"]')
+ .attributes('value'),
+ ).toBe('mock-csrf-token');
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js
new file mode 100644
index 00000000000..f766ad5b0d1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/action_buttons/user_action_buttons_spec.js
@@ -0,0 +1,89 @@
+import { shallowMount } from '@vue/test-utils';
+import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue';
+import RemoveMemberButton from '~/vue_shared/components/members/action_buttons/remove_member_button.vue';
+import LeaveButton from '~/vue_shared/components/members/action_buttons/leave_button.vue';
+import { member, orphanedMember } from '../mock_data';
+
+describe('UserActionButtons', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(UserActionButtons, {
+ propsData: {
+ member,
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ const findRemoveMemberButton = () => wrapper.find(RemoveMemberButton);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when user has `canRemove` permissions', () => {
+ beforeEach(() => {
+ createComponent({
+ permissions: {
+ canRemove: true,
+ },
+ });
+ });
+
+ it('renders remove member button', () => {
+ expect(findRemoveMemberButton().exists()).toBe(true);
+ });
+
+ it('sets props correctly', () => {
+ expect(findRemoveMemberButton().props()).toEqual({
+ memberId: member.id,
+ message: `Are you sure you want to remove ${member.user.name} from "${member.source.name}"`,
+ title: 'Remove member',
+ isAccessRequest: false,
+ icon: 'remove',
+ });
+ });
+
+ describe('when member is orphaned', () => {
+ it('sets `message` prop correctly', () => {
+ createComponent({
+ member: orphanedMember,
+ permissions: {
+ canRemove: true,
+ },
+ });
+
+ expect(findRemoveMemberButton().props('message')).toBe(
+ `Are you sure you want to remove this orphaned member from "${orphanedMember.source.name}"`,
+ );
+ });
+ });
+
+ describe('when member is the current user', () => {
+ it('renders leave button', () => {
+ createComponent({
+ isCurrentUser: true,
+ permissions: {
+ canRemove: true,
+ },
+ });
+
+ expect(wrapper.find(LeaveButton).exists()).toBe(true);
+ });
+ });
+ });
+
+ describe('when user does not have `canRemove` permissions', () => {
+ it('does not render remove member button', () => {
+ createComponent({
+ permissions: {
+ canRemove: false,
+ },
+ });
+
+ expect(findRemoveMemberButton().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
new file mode 100644
index 00000000000..d6f5773295c
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/group_avatar_spec.js
@@ -0,0 +1,46 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { GlAvatarLink } from '@gitlab/ui';
+import { group as member } from '../mock_data';
+import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const group = member.sharedWithGroup;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(GroupAvatar, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders link to group', () => {
+ const link = wrapper.find(GlAvatarLink);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe(group.webUrl);
+ });
+
+ it("renders group's full name", () => {
+ expect(getByText(group.fullName).exists()).toBe(true);
+ });
+
+ it("renders group's avatar", () => {
+ expect(wrapper.find('img').attributes('src')).toBe(group.avatarUrl);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
new file mode 100644
index 00000000000..7948da7eb40
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/invite_avatar_spec.js
@@ -0,0 +1,38 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { invite as member } from '../mock_data';
+import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const { invite } = member;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(InviteAvatar, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ beforeEach(() => {
+ createComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('renders email as name', () => {
+ expect(getByText(invite.email).exists()).toBe(true);
+ });
+
+ it('renders avatar', () => {
+ expect(wrapper.find('img').attributes('src')).toBe(invite.avatarUrl);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
new file mode 100644
index 00000000000..93d8e640968
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/avatars/user_avatar_spec.js
@@ -0,0 +1,115 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { GlAvatarLink, GlBadge } from '@gitlab/ui';
+import { member as memberMock, orphanedMember } from '../mock_data';
+import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
+
+describe('UserAvatar', () => {
+ let wrapper;
+
+ const { user } = memberMock;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(UserAvatar, {
+ propsData: {
+ member: memberMock,
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).findByText(text, options));
+
+ const findStatusEmoji = emoji => wrapper.find(`gl-emoji[data-name="${emoji}"]`);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it("renders link to user's profile", () => {
+ createComponent();
+
+ const link = wrapper.find(GlAvatarLink);
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: user.webUrl,
+ 'data-user-id': `${user.id}`,
+ 'data-username': user.username,
+ });
+ });
+
+ it("renders user's name", () => {
+ createComponent();
+
+ expect(getByText(user.name).exists()).toBe(true);
+ });
+
+ it("renders user's username", () => {
+ createComponent();
+
+ expect(getByText(`@${user.username}`).exists()).toBe(true);
+ });
+
+ it("renders user's avatar", () => {
+ createComponent();
+
+ expect(wrapper.find('img').attributes('src')).toBe(user.avatarUrl);
+ });
+
+ describe('when user property does not exist', () => {
+ it('displays an orphaned user', () => {
+ createComponent({ member: orphanedMember });
+
+ expect(getByText('Orphaned member').exists()).toBe(true);
+ });
+ });
+
+ describe('badges', () => {
+ it.each`
+ member | badgeText
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${'Blocked'}
+ ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${'2FA'}
+ `('renders the "$badgeText" badge', ({ member, badgeText }) => {
+ createComponent({ member });
+
+ expect(wrapper.find(GlBadge).text()).toBe(badgeText);
+ });
+
+ it('renders the "It\'s you" badge when member is current user', () => {
+ createComponent({ isCurrentUser: true });
+
+ expect(getByText("It's you").exists()).toBe(true);
+ });
+ });
+
+ describe('user status', () => {
+ const emoji = 'island';
+
+ describe('when set', () => {
+ it('displays the status emoji', () => {
+ createComponent({
+ member: {
+ ...memberMock,
+ user: {
+ ...memberMock.user,
+ status: { emoji, messageHtml: 'On vacation' },
+ },
+ },
+ });
+
+ expect(findStatusEmoji(emoji).exists()).toBe(true);
+ });
+ });
+
+ describe('when not set', () => {
+ it('does not display status emoji', () => {
+ createComponent();
+
+ expect(findStatusEmoji(emoji).exists()).toBe(false);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/mock_data.js b/spec/frontend/vue_shared/components/members/mock_data.js
new file mode 100644
index 00000000000..d7bb8c0d142
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/mock_data.js
@@ -0,0 +1,70 @@
+export const member = {
+ requestedAt: null,
+ canUpdate: false,
+ canRemove: false,
+ canOverride: false,
+ accessLevel: { integerValue: 50, stringValue: 'Owner' },
+ source: {
+ id: 178,
+ name: 'Foo Bar',
+ webUrl: 'https://gitlab.com/groups/foo-bar',
+ },
+ user: {
+ id: 123,
+ name: 'Administrator',
+ username: 'root',
+ webUrl: 'https://gitlab.com/root',
+ avatarUrl: 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon',
+ blocked: false,
+ twoFactorEnabled: false,
+ },
+ id: 238,
+ createdAt: '2020-07-17T16:22:46.923Z',
+ expiresAt: null,
+ usingLicense: false,
+ groupSso: false,
+ groupManagedAccount: false,
+ validRoles: {
+ Guest: 10,
+ Reporter: 20,
+ Developer: 30,
+ Maintainer: 40,
+ Owner: 50,
+ 'Minimal Access': 5,
+ },
+};
+
+export const group = {
+ accessLevel: { integerValue: 10, stringValue: 'Guest' },
+ sharedWithGroup: {
+ id: 24,
+ name: 'Commit451',
+ avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png?width=40',
+ fullPath: 'parent-group/commit451',
+ fullName: 'Parent group / Commit451',
+ webUrl: 'https://gitlab.com/groups/parent-group/commit451',
+ },
+ id: 3,
+ createdAt: '2020-08-06T15:31:07.662Z',
+ expiresAt: null,
+ validRoles: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 },
+};
+
+const { user, ...memberNoUser } = member;
+export const invite = {
+ ...memberNoUser,
+ invite: {
+ email: 'jewel@hudsonwalter.biz',
+ avatarUrl: 'https://www.gravatar.com/avatar/cbab7510da7eec2f60f638261b05436d?s=80&d=identicon',
+ canResend: true,
+ },
+};
+
+export const orphanedMember = memberNoUser;
+
+export const accessRequest = {
+ ...member,
+ requestedAt: '2020-07-17T16:22:46.923Z',
+};
+
+export const members = [member];
diff --git a/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js
new file mode 100644
index 00000000000..63de355a3c8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/modals/leave_modal_spec.js
@@ -0,0 +1,91 @@
+import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
+import { GlModal, GlForm } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { within } from '@testing-library/dom';
+import Vuex from 'vuex';
+import LeaveModal from '~/vue_shared/components/members/modals/leave_modal.vue';
+import { LEAVE_MODAL_ID } from '~/vue_shared/components/members/constants';
+import { member } from '../mock_data';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('LeaveModal', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ memberPath: '/groups/foo-bar/-/group_members/:id',
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = (propsData = {}, state) => {
+ wrapper = mount(LeaveModal, {
+ localVue,
+ store: createStore(state),
+ propsData: {
+ member,
+ ...propsData,
+ },
+ attrs: {
+ static: true,
+ visible: true,
+ },
+ });
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+
+ const findForm = () => findModal().find(GlForm);
+
+ const getByText = (text, options) =>
+ createWrapper(within(findModal().element).getByText(text, options));
+
+ beforeEach(async () => {
+ createComponent();
+ await nextTick();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('sets modal ID', () => {
+ expect(findModal().props('modalId')).toBe(LEAVE_MODAL_ID);
+ });
+
+ it('displays modal title', () => {
+ expect(getByText(`Leave "${member.source.name}"`).exists()).toBe(true);
+ });
+
+ it('displays modal body', () => {
+ expect(getByText(`Are you sure you want to leave "${member.source.name}"?`).exists()).toBe(
+ true,
+ );
+ });
+
+ it('displays form with correct action and inputs', () => {
+ const form = findForm();
+
+ expect(form.attributes('action')).toBe('/groups/foo-bar/-/group_members/leave');
+ expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
+ expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+ });
+
+ it('submits the form when "Leave" button is clicked', () => {
+ const submitSpy = jest.spyOn(findForm().element, 'submit');
+
+ getByText('Leave').trigger('click');
+
+ expect(submitSpy).toHaveBeenCalled();
+
+ submitSpy.mockRestore();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js
new file mode 100644
index 00000000000..84da051792d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/modals/remove_group_link_modal_spec.js
@@ -0,0 +1,106 @@
+import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
+import { GlModal, GlForm } from '@gitlab/ui';
+import { nextTick } from 'vue';
+import { within } from '@testing-library/dom';
+import Vuex from 'vuex';
+import RemoveGroupLinkModal from '~/vue_shared/components/members/modals/remove_group_link_modal.vue';
+import { REMOVE_GROUP_LINK_MODAL_ID } from '~/vue_shared/components/members/constants';
+import { group } from '../mock_data';
+
+jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' }));
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('RemoveGroupLinkModal', () => {
+ let wrapper;
+
+ const actions = {
+ hideRemoveGroupLinkModal: jest.fn(),
+ };
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ memberPath: '/groups/foo-bar/-/group_links/:id',
+ groupLinkToRemove: group,
+ removeGroupLinkModalVisible: true,
+ ...state,
+ },
+ actions,
+ });
+ };
+
+ const createComponent = state => {
+ wrapper = mount(RemoveGroupLinkModal, {
+ localVue,
+ store: createStore(state),
+ attrs: {
+ static: true,
+ },
+ });
+ };
+
+ const findModal = () => wrapper.find(GlModal);
+ const findForm = () => findModal().find(GlForm);
+ const getByText = (text, options) =>
+ createWrapper(within(findModal().element).getByText(text, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when modal is open', () => {
+ beforeEach(async () => {
+ createComponent();
+ await nextTick();
+ });
+
+ it('sets modal ID', () => {
+ expect(findModal().props('modalId')).toBe(REMOVE_GROUP_LINK_MODAL_ID);
+ });
+
+ it('displays modal title', () => {
+ expect(getByText(`Remove "${group.sharedWithGroup.fullName}"`).exists()).toBe(true);
+ });
+
+ it('displays modal body', () => {
+ expect(
+ getByText(`Are you sure you want to remove "${group.sharedWithGroup.fullName}"?`).exists(),
+ ).toBe(true);
+ });
+
+ it('displays form with correct action and inputs', () => {
+ const form = findForm();
+
+ expect(form.attributes('action')).toBe(`/groups/foo-bar/-/group_links/${group.id}`);
+ expect(form.find('input[name="_method"]').attributes('value')).toBe('delete');
+ expect(form.find('input[name="authenticity_token"]').attributes('value')).toBe(
+ 'mock-csrf-token',
+ );
+ });
+
+ it('submits the form when "Remove group" button is clicked', () => {
+ const submitSpy = jest.spyOn(findForm().element, 'submit');
+
+ getByText('Remove group').trigger('click');
+
+ expect(submitSpy).toHaveBeenCalled();
+
+ submitSpy.mockRestore();
+ });
+
+ it('calls `hideRemoveGroupLinkModal` action when modal is closed', () => {
+ getByText('Cancel').trigger('click');
+
+ expect(actions.hideRemoveGroupLinkModal).toHaveBeenCalled();
+ });
+ });
+
+ it('modal does not show when `removeGroupLinkModalVisible` is `false`', () => {
+ createComponent({ removeGroupLinkModalVisible: false });
+
+ expect(findModal().vm.$attrs.visible).toBe(false);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/created_at_spec.js b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
new file mode 100644
index 00000000000..cf3821baf44
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/created_at_spec.js
@@ -0,0 +1,61 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { useFakeDate } from 'helpers/fake_date';
+import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+describe('CreatedAt', () => {
+ // March 15th, 2020
+ useFakeDate(2020, 2, 15);
+
+ const date = '2020-03-01T00:00:00.000';
+ const dateTimeAgo = '2 weeks ago';
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(CreatedAt, {
+ propsData: {
+ date,
+ ...propsData,
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).getByText(text, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('created at text', () => {
+ beforeEach(() => {
+ createComponent();
+ });
+
+ it('displays created at text', () => {
+ expect(getByText(dateTimeAgo).exists()).toBe(true);
+ });
+
+ it('uses `TimeAgoTooltip` component to display tooltip', () => {
+ expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true);
+ });
+ });
+
+ describe('when `createdBy` prop is provided', () => {
+ it('displays a link to the user that created the member', () => {
+ createComponent({
+ createdBy: {
+ name: 'Administrator',
+ webUrl: 'https://gitlab.com/root',
+ },
+ });
+
+ const link = getByText('Administrator');
+
+ expect(link.exists()).toBe(true);
+ expect(link.attributes('href')).toBe('https://gitlab.com/root');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/expires_at_spec.js b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
new file mode 100644
index 00000000000..95ae251b0fd
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/expires_at_spec.js
@@ -0,0 +1,86 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { within } from '@testing-library/dom';
+import { useFakeDate } from 'helpers/fake_date';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
+
+describe('ExpiresAt', () => {
+ // March 15th, 2020
+ useFakeDate(2020, 2, 15);
+
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(ExpiresAt, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(within(wrapper.element).getByText(text, options));
+
+ const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when no expiration date is set', () => {
+ it('displays "No expiration set"', () => {
+ createComponent({ date: null });
+
+ expect(getByText('No expiration set').exists()).toBe(true);
+ });
+ });
+
+ describe('when expiration date is in the past', () => {
+ let expiredText;
+
+ beforeEach(() => {
+ createComponent({ date: '2019-03-15T00:00:00.000' });
+
+ expiredText = getByText('Expired');
+ });
+
+ it('displays "Expired"', () => {
+ expect(expiredText.exists()).toBe(true);
+ expect(expiredText.classes()).toContain('gl-text-red-500');
+ });
+
+ it('displays tooltip with formatted date', () => {
+ const tooltipDirective = getTooltipDirective(expiredText);
+
+ expect(tooltipDirective).not.toBeUndefined();
+ expect(expiredText.attributes('title')).toBe('Mar 15, 2019 12:00am GMT+0000');
+ });
+ });
+
+ describe('when expiration date is in the future', () => {
+ it.each`
+ date | expected | warningColor
+ ${'2020-03-23T00:00:00.000'} | ${'in 8 days'} | ${false}
+ ${'2020-03-20T00:00:00.000'} | ${'in 5 days'} | ${true}
+ ${'2020-03-16T00:00:00.000'} | ${'in 1 day'} | ${true}
+ ${'2020-03-15T05:00:00.000'} | ${'in about 5 hours'} | ${true}
+ ${'2020-03-15T01:00:00.000'} | ${'in about 1 hour'} | ${true}
+ ${'2020-03-15T00:30:00.000'} | ${'in 30 minutes'} | ${true}
+ ${'2020-03-15T00:01:15.000'} | ${'in 1 minute'} | ${true}
+ ${'2020-03-15T00:00:15.000'} | ${'in less than a minute'} | ${true}
+ `('displays "$expected"', ({ date, expected, warningColor }) => {
+ createComponent({ date });
+
+ const expiredText = getByText(expected);
+
+ expect(expiredText.exists()).toBe(true);
+
+ if (warningColor) {
+ expect(expiredText.classes()).toContain('gl-text-orange-500');
+ } else {
+ expect(expiredText.classes()).not.toContain('gl-text-orange-500');
+ }
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js
new file mode 100644
index 00000000000..e55d9b6be2a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_action_buttons_spec.js
@@ -0,0 +1,43 @@
+import { shallowMount } from '@vue/test-utils';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
+import UserActionButtons from '~/vue_shared/components/members/action_buttons/user_action_buttons.vue';
+import GroupActionButtons from '~/vue_shared/components/members/action_buttons/group_action_buttons.vue';
+import InviteActionButtons from '~/vue_shared/components/members/action_buttons/invite_action_buttons.vue';
+import AccessRequestActionButtons from '~/vue_shared/components/members/action_buttons/access_request_action_buttons.vue';
+
+describe('MemberActionButtons', () => {
+ let wrapper;
+
+ const createComponent = (propsData = {}) => {
+ wrapper = shallowMount(MemberActionButtons, {
+ propsData: {
+ isCurrentUser: false,
+ permissions: {
+ canRemove: true,
+ },
+ ...propsData,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ test.each`
+ memberType | member | expectedComponent | expectedComponentName
+ ${MEMBER_TYPES.user} | ${memberMock} | ${UserActionButtons} | ${'UserActionButtons'}
+ ${MEMBER_TYPES.group} | ${group} | ${GroupActionButtons} | ${'GroupActionButtons'}
+ ${MEMBER_TYPES.invite} | ${invite} | ${InviteActionButtons} | ${'InviteActionButtons'}
+ ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${AccessRequestActionButtons} | ${'AccessRequestActionButtons'}
+ `(
+ 'renders $expectedComponentName when `memberType` is $memberType',
+ ({ memberType, member, expectedComponent }) => {
+ createComponent({ memberType, member });
+
+ expect(wrapper.find(expectedComponent).exists()).toBe(true);
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
new file mode 100644
index 00000000000..a171dd830c1
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_avatar_spec.js
@@ -0,0 +1,39 @@
+import { shallowMount } from '@vue/test-utils';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
+import UserAvatar from '~/vue_shared/components/members/avatars/user_avatar.vue';
+import GroupAvatar from '~/vue_shared/components/members/avatars/group_avatar.vue';
+import InviteAvatar from '~/vue_shared/components/members/avatars/invite_avatar.vue';
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = shallowMount(MemberAvatar, {
+ propsData: {
+ isCurrentUser: false,
+ ...propsData,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ test.each`
+ memberType | member | expectedComponent | expectedComponentName
+ ${MEMBER_TYPES.user} | ${memberMock} | ${UserAvatar} | ${'UserAvatar'}
+ ${MEMBER_TYPES.group} | ${group} | ${GroupAvatar} | ${'GroupAvatar'}
+ ${MEMBER_TYPES.invite} | ${invite} | ${InviteAvatar} | ${'InviteAvatar'}
+ ${MEMBER_TYPES.accessRequest} | ${accessRequest} | ${UserAvatar} | ${'UserAvatar'}
+ `(
+ 'renders $expectedComponentName when `memberType` is $memberType',
+ ({ memberType, member, expectedComponent }) => {
+ createComponent({ memberType, member });
+
+ expect(wrapper.find(expectedComponent).exists()).toBe(true);
+ },
+ );
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_source_spec.js b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
new file mode 100644
index 00000000000..8b914d76674
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_source_spec.js
@@ -0,0 +1,71 @@
+import { mount, createWrapper } from '@vue/test-utils';
+import { getByText as getByTextHelper } from '@testing-library/dom';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
+
+describe('MemberSource', () => {
+ let wrapper;
+
+ const createComponent = propsData => {
+ wrapper = mount(MemberSource, {
+ propsData: {
+ memberSource: {
+ id: 102,
+ name: 'Foo bar',
+ webUrl: 'https://gitlab.com/groups/foo-bar',
+ },
+ ...propsData,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ const getTooltipDirective = elementWrapper => getBinding(elementWrapper.element, 'gl-tooltip');
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('direct member', () => {
+ it('displays "Direct member"', () => {
+ createComponent({
+ isDirectMember: true,
+ });
+
+ expect(getByText('Direct member').exists()).toBe(true);
+ });
+ });
+
+ describe('inherited member', () => {
+ let sourceGroupLink;
+
+ beforeEach(() => {
+ createComponent({
+ isDirectMember: false,
+ });
+
+ sourceGroupLink = getByText('Foo bar');
+ });
+
+ it('displays a link to source group', () => {
+ createComponent({
+ isDirectMember: false,
+ });
+
+ expect(sourceGroupLink.exists()).toBe(true);
+ expect(sourceGroupLink.attributes('href')).toBe('https://gitlab.com/groups/foo-bar');
+ });
+
+ it('displays tooltip with "Inherited"', () => {
+ const tooltipDirective = getTooltipDirective(sourceGroupLink);
+
+ expect(tooltipDirective).not.toBeUndefined();
+ expect(sourceGroupLink.attributes('title')).toBe('Inherited');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
new file mode 100644
index 00000000000..ba693975a88
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/member_table_cell_spec.js
@@ -0,0 +1,251 @@
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { MEMBER_TYPES } from '~/vue_shared/components/members/constants';
+import { member as memberMock, group, invite, accessRequest } from '../mock_data';
+import MembersTableCell from '~/vue_shared/components/members/table/members_table_cell.vue';
+
+describe('MemberList', () => {
+ const WrappedComponent = {
+ props: {
+ memberType: {
+ type: String,
+ required: true,
+ },
+ isDirectMember: {
+ type: Boolean,
+ required: true,
+ },
+ isCurrentUser: {
+ type: Boolean,
+ required: true,
+ },
+ permissions: {
+ type: Object,
+ required: true,
+ },
+ },
+ render(createElement) {
+ return createElement('div', this.memberType);
+ },
+ };
+
+ const localVue = createLocalVue();
+ localVue.use(Vuex);
+ localVue.component('wrapped-component', WrappedComponent);
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ sourceId: 1,
+ currentUserId: 1,
+ ...state,
+ },
+ });
+ };
+
+ let wrapper;
+
+ const createComponent = (propsData, state = {}) => {
+ wrapper = mount(MembersTableCell, {
+ localVue,
+ propsData,
+ store: createStore(state),
+ scopedSlots: {
+ default: `
+ <wrapped-component
+ :member-type="props.memberType"
+ :is-direct-member="props.isDirectMember"
+ :is-current-user="props.isCurrentUser"
+ :permissions="props.permissions"
+ />
+ `,
+ },
+ });
+ };
+
+ const findWrappedComponent = () => wrapper.find(WrappedComponent);
+
+ const memberCurrentUser = {
+ ...memberMock,
+ user: {
+ ...memberMock.user,
+ id: 1,
+ },
+ };
+
+ const createComponentWithDirectMember = (member = {}) => {
+ createComponent({
+ member: {
+ ...memberMock,
+ source: {
+ ...memberMock.source,
+ id: 1,
+ },
+ ...member,
+ },
+ });
+ };
+ const createComponentWithInheritedMember = (member = {}) => {
+ createComponent({
+ member: { ...memberMock, ...member },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ test.each`
+ member | expectedMemberType
+ ${memberMock} | ${MEMBER_TYPES.user}
+ ${group} | ${MEMBER_TYPES.group}
+ ${invite} | ${MEMBER_TYPES.invite}
+ ${accessRequest} | ${MEMBER_TYPES.accessRequest}
+ `(
+ 'sets scoped slot prop `memberType` to $expectedMemberType',
+ ({ member, expectedMemberType }) => {
+ createComponent({ member });
+
+ expect(findWrappedComponent().props('memberType')).toBe(expectedMemberType);
+ },
+ );
+
+ describe('isDirectMember', () => {
+ it('returns `true` when member source has same ID as `sourceId`', () => {
+ createComponentWithDirectMember();
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(true);
+ });
+
+ it('returns `false` when member is inherited', () => {
+ createComponentWithInheritedMember();
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(false);
+ });
+
+ it('returns `true` for linked groups', () => {
+ createComponent({
+ member: group,
+ });
+
+ expect(findWrappedComponent().props('isDirectMember')).toBe(true);
+ });
+ });
+
+ describe('isCurrentUser', () => {
+ it('returns `true` when `member.user` has the same ID as `currentUserId`', () => {
+ createComponent({
+ member: memberCurrentUser,
+ });
+
+ expect(findWrappedComponent().props('isCurrentUser')).toBe(true);
+ });
+
+ it('returns `false` when `member.user` does not have the same ID as `currentUserId`', () => {
+ createComponent({
+ member: memberMock,
+ });
+
+ expect(findWrappedComponent().props('isCurrentUser')).toBe(false);
+ });
+ });
+
+ describe('permissions', () => {
+ describe('canRemove', () => {
+ describe('for a direct member', () => {
+ it('returns `true` when `canRemove` is `true`', () => {
+ createComponentWithDirectMember({
+ canRemove: true,
+ });
+
+ expect(findWrappedComponent().props('permissions').canRemove).toBe(true);
+ });
+
+ it('returns `false` when `canRemove` is `false`', () => {
+ createComponentWithDirectMember({
+ canRemove: false,
+ });
+
+ expect(findWrappedComponent().props('permissions').canRemove).toBe(false);
+ });
+ });
+
+ describe('for an inherited member', () => {
+ it('returns `false`', () => {
+ createComponentWithInheritedMember();
+
+ expect(findWrappedComponent().props('permissions').canRemove).toBe(false);
+ });
+ });
+ });
+
+ describe('canResend', () => {
+ describe('when member type is `invite`', () => {
+ it('returns `true` when `canResend` is `true`', () => {
+ createComponent({
+ member: invite,
+ });
+
+ expect(findWrappedComponent().props('permissions').canResend).toBe(true);
+ });
+
+ it('returns `false` when `canResend` is `false`', () => {
+ createComponent({
+ member: {
+ ...invite,
+ invite: {
+ ...invite,
+ canResend: false,
+ },
+ },
+ });
+
+ expect(findWrappedComponent().props('permissions').canResend).toBe(false);
+ });
+ });
+
+ describe('when member type is not `invite`', () => {
+ it('returns `false`', () => {
+ createComponent({ member: memberMock });
+
+ expect(findWrappedComponent().props('permissions').canResend).toBe(false);
+ });
+ });
+ });
+
+ describe('canUpdate', () => {
+ describe('for a direct member', () => {
+ it('returns `true` when `canUpdate` is `true`', () => {
+ createComponentWithDirectMember({
+ canUpdate: true,
+ });
+
+ expect(findWrappedComponent().props('permissions').canUpdate).toBe(true);
+ });
+
+ it('returns `false` when `canUpdate` is `false`', () => {
+ createComponentWithDirectMember({
+ canUpdate: false,
+ });
+
+ expect(findWrappedComponent().props('permissions').canUpdate).toBe(false);
+ });
+
+ it('returns `false` for current user', () => {
+ createComponentWithDirectMember(memberCurrentUser);
+
+ expect(findWrappedComponent().props('permissions').canUpdate).toBe(false);
+ });
+ });
+
+ describe('for an inherited member', () => {
+ it('returns `false`', () => {
+ createComponentWithInheritedMember();
+
+ expect(findWrappedComponent().props('permissions').canUpdate).toBe(false);
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/members_table_spec.js b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
new file mode 100644
index 00000000000..20c1c26d2ee
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/members_table_spec.js
@@ -0,0 +1,141 @@
+import { mount, createLocalVue, createWrapper } from '@vue/test-utils';
+import Vuex from 'vuex';
+import {
+ getByText as getByTextHelper,
+ getByTestId as getByTestIdHelper,
+} from '@testing-library/dom';
+import { GlBadge } from '@gitlab/ui';
+import MembersTable from '~/vue_shared/components/members/table/members_table.vue';
+import MemberAvatar from '~/vue_shared/components/members/table/member_avatar.vue';
+import MemberSource from '~/vue_shared/components/members/table/member_source.vue';
+import ExpiresAt from '~/vue_shared/components/members/table/expires_at.vue';
+import CreatedAt from '~/vue_shared/components/members/table/created_at.vue';
+import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue';
+import MemberActionButtons from '~/vue_shared/components/members/table/member_action_buttons.vue';
+import * as initUserPopovers from '~/user_popovers';
+import { member as memberMock, invite, accessRequest } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('MemberList', () => {
+ let wrapper;
+
+ const createStore = (state = {}) => {
+ return new Vuex.Store({
+ state: {
+ members: [],
+ tableFields: [],
+ sourceId: 1,
+ ...state,
+ },
+ });
+ };
+
+ const createComponent = state => {
+ wrapper = mount(MembersTable, {
+ localVue,
+ store: createStore(state),
+ stubs: [
+ 'member-avatar',
+ 'member-source',
+ 'expires-at',
+ 'created-at',
+ 'member-action-buttons',
+ 'role-dropdown',
+ 'remove-group-link-modal',
+ ],
+ });
+ };
+
+ const getByText = (text, options) =>
+ createWrapper(getByTextHelper(wrapper.element, text, options));
+
+ const getByTestId = (id, options) =>
+ createWrapper(getByTestIdHelper(wrapper.element, id, options));
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('fields', () => {
+ const memberCanUpdate = {
+ ...memberMock,
+ canUpdate: true,
+ source: { ...memberMock.source, id: 1 },
+ };
+
+ it.each`
+ field | label | member | expectedComponent
+ ${'account'} | ${'Account'} | ${memberMock} | ${MemberAvatar}
+ ${'source'} | ${'Source'} | ${memberMock} | ${MemberSource}
+ ${'granted'} | ${'Access granted'} | ${memberMock} | ${CreatedAt}
+ ${'invited'} | ${'Invited'} | ${invite} | ${CreatedAt}
+ ${'requested'} | ${'Requested'} | ${accessRequest} | ${CreatedAt}
+ ${'expires'} | ${'Access expires'} | ${memberMock} | ${ExpiresAt}
+ ${'maxRole'} | ${'Max role'} | ${memberCanUpdate} | ${RoleDropdown}
+ ${'expiration'} | ${'Expiration'} | ${memberMock} | ${null}
+ `('renders the $label field', ({ field, label, member, expectedComponent }) => {
+ createComponent({
+ members: [member],
+ tableFields: [field],
+ });
+
+ expect(getByText(label, { selector: '[role="columnheader"]' }).exists()).toBe(true);
+
+ if (expectedComponent) {
+ expect(
+ wrapper
+ .find(`[data-label="${label}"][role="cell"]`)
+ .find(expectedComponent)
+ .exists(),
+ ).toBe(true);
+ }
+ });
+
+ it('renders "Actions" field for screen readers', () => {
+ createComponent({ members: [memberMock], tableFields: ['actions'] });
+
+ const actionField = getByTestId('col-actions');
+
+ expect(actionField.exists()).toBe(true);
+ expect(actionField.classes('gl-sr-only')).toBe(true);
+ expect(
+ wrapper
+ .find(`[data-label="Actions"][role="cell"]`)
+ .find(MemberActionButtons)
+ .exists(),
+ ).toBe(true);
+ });
+ });
+
+ describe('when `members` is an empty array', () => {
+ it('displays a "No members found" message', () => {
+ createComponent();
+
+ expect(getByText('No members found').exists()).toBe(true);
+ });
+ });
+
+ describe('when member can not be updated', () => {
+ it('renders badge in "Max role" field', () => {
+ createComponent({ members: [memberMock], tableFields: ['maxRole'] });
+
+ expect(
+ wrapper
+ .find(`[data-label="Max role"][role="cell"]`)
+ .find(GlBadge)
+ .text(),
+ ).toBe(memberMock.accessLevel.stringValue);
+ });
+ });
+
+ it('initializes user popovers when mounted', () => {
+ const initUserPopoversMock = jest.spyOn(initUserPopovers, 'default');
+
+ createComponent();
+
+ expect(initUserPopoversMock).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js
new file mode 100644
index 00000000000..1e47953a510
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/table/role_dropdown_spec.js
@@ -0,0 +1,150 @@
+import { mount, createWrapper, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import { nextTick } from 'vue';
+import { within } from '@testing-library/dom';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { GlBreakpointInstance as bp } from '@gitlab/ui/dist/utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import RoleDropdown from '~/vue_shared/components/members/table/role_dropdown.vue';
+import { member } from '../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('RoleDropdown', () => {
+ let wrapper;
+ let actions;
+ const $toast = {
+ show: jest.fn(),
+ };
+
+ const createStore = () => {
+ actions = {
+ updateMemberRole: jest.fn(() => Promise.resolve()),
+ };
+
+ return new Vuex.Store({ actions });
+ };
+
+ const createComponent = (propsData = {}) => {
+ wrapper = mount(RoleDropdown, {
+ propsData: {
+ member,
+ ...propsData,
+ },
+ localVue,
+ store: createStore(),
+ mocks: {
+ $toast,
+ },
+ });
+ };
+
+ const getDropdownMenu = () => within(wrapper.element).getByRole('menu');
+ const getByTextInDropdownMenu = (text, options = {}) =>
+ createWrapper(within(getDropdownMenu()).getByText(text, options));
+ const getDropdownItemByText = text =>
+ createWrapper(
+ within(getDropdownMenu())
+ .getByText(text, { selector: '[role="menuitem"] p' })
+ .closest('[role="menuitem"]'),
+ );
+ const getCheckedDropdownItem = () =>
+ wrapper
+ .findAll(GlDropdownItem)
+ .wrappers.find(dropdownItemWrapper => dropdownItemWrapper.props('isChecked'));
+
+ const findDropdownToggle = () => wrapper.find('button[aria-haspopup="true"]');
+ const findDropdown = () => wrapper.find(GlDropdown);
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ describe('when dropdown is open', () => {
+ beforeEach(done => {
+ createComponent();
+
+ findDropdownToggle().trigger('click');
+ wrapper.vm.$root.$on('bv::dropdown::shown', () => {
+ done();
+ });
+ });
+
+ it('renders all valid roles', () => {
+ Object.keys(member.validRoles).forEach(role => {
+ expect(getDropdownItemByText(role).exists()).toBe(true);
+ });
+ });
+
+ it('renders dropdown header', () => {
+ expect(getByTextInDropdownMenu('Change permissions').exists()).toBe(true);
+ });
+
+ it('sets dropdown toggle and checks selected role', () => {
+ expect(findDropdownToggle().text()).toBe('Owner');
+ expect(getCheckedDropdownItem().text()).toBe('Owner');
+ });
+
+ describe('when dropdown item is selected', () => {
+ it('does nothing if the item selected was already selected', () => {
+ getDropdownItemByText('Owner').trigger('click');
+
+ expect(actions.updateMemberRole).not.toHaveBeenCalled();
+ });
+
+ it('calls `updateMemberRole` Vuex action', () => {
+ getDropdownItemByText('Developer').trigger('click');
+
+ expect(actions.updateMemberRole).toHaveBeenCalledWith(expect.any(Object), {
+ memberId: member.id,
+ accessLevel: { integerValue: 30, stringValue: 'Developer' },
+ });
+ });
+
+ it('displays toast when successful', async () => {
+ getDropdownItemByText('Developer').trigger('click');
+
+ await waitForPromises();
+
+ expect($toast.show).toHaveBeenCalledWith('Role updated successfully.');
+ });
+
+ it('disables dropdown while waiting for `updateMemberRole` to resolve', async () => {
+ getDropdownItemByText('Developer').trigger('click');
+
+ await nextTick();
+
+ expect(findDropdown().attributes('disabled')).toBe('disabled');
+
+ await waitForPromises();
+
+ expect(findDropdown().attributes('disabled')).toBeUndefined();
+ });
+ });
+ });
+
+ it("sets initial dropdown toggle value to member's role", () => {
+ createComponent();
+
+ expect(findDropdownToggle().text()).toBe('Owner');
+ });
+
+ it('sets the dropdown alignment to right on mobile', async () => {
+ jest.spyOn(bp, 'isDesktop').mockReturnValue(false);
+ createComponent();
+
+ await nextTick();
+
+ expect(findDropdown().attributes('right')).toBe('true');
+ });
+
+ it('sets the dropdown alignment to left on desktop', async () => {
+ jest.spyOn(bp, 'isDesktop').mockReturnValue(true);
+ createComponent();
+
+ await nextTick();
+
+ expect(findDropdown().attributes('right')).toBeUndefined();
+ });
+});
diff --git a/spec/frontend/vue_shared/components/members/utils_spec.js b/spec/frontend/vue_shared/components/members/utils_spec.js
new file mode 100644
index 00000000000..f183abc08d6
--- /dev/null
+++ b/spec/frontend/vue_shared/components/members/utils_spec.js
@@ -0,0 +1,29 @@
+import { generateBadges } from '~/vue_shared/components/members/utils';
+import { member as memberMock } from './mock_data';
+
+describe('Members Utils', () => {
+ describe('generateBadges', () => {
+ it('has correct properties for each badge', () => {
+ const badges = generateBadges(memberMock, true);
+
+ badges.forEach(badge => {
+ expect(badge).toEqual(
+ expect.objectContaining({
+ show: expect.any(Boolean),
+ text: expect.any(String),
+ variant: expect.stringMatching(/muted|neutral|info|success|danger|warning/),
+ }),
+ );
+ });
+ });
+
+ it.each`
+ member | expected
+ ${memberMock} | ${{ show: true, text: "It's you", variant: 'success' }}
+ ${{ ...memberMock, user: { ...memberMock.user, blocked: true } }} | ${{ show: true, text: 'Blocked', variant: 'danger' }}
+ ${{ ...memberMock, user: { ...memberMock.user, twoFactorEnabled: true } }} | ${{ show: true, text: '2FA', variant: 'info' }}
+ `('returns expected output for "$expected.text" badge', ({ member, expected }) => {
+ expect(generateBadges(member, true)).toContainEqual(expect.objectContaining(expected));
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json
new file mode 100644
index 00000000000..0d85b2bc68a
--- /dev/null
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items.json
@@ -0,0 +1,15 @@
+[
+ {
+ "iid": "1527542",
+ "title": "SyntaxError: Invalid or unexpected token",
+ "createdAt": "2020-04-17T23:18:14.996Z",
+ "assignees": { "nodes": [] }
+ },
+ {
+ "iid": "1527543",
+ "title": "SyntaxError: Invalid or unexpected token by root",
+ "createdAt": "2020-04-17T23:19:14.996Z",
+ "assignees": { "nodes": [] }
+ }
+ ]
+ \ No newline at end of file
diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json
new file mode 100644
index 00000000000..b42ec42d8b8
--- /dev/null
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json
@@ -0,0 +1,14 @@
+[
+ {
+ "type": "assignee_username",
+ "value": { "data": "root2" }
+ },
+ {
+ "type": "author_username",
+ "value": { "data": "root" }
+ },
+ {
+ "type": "filtered-search-term",
+ "value": { "data": "bar" }
+ }
+ ] \ No newline at end of file
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
new file mode 100644
index 00000000000..d943aaf3e5f
--- /dev/null
+++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js
@@ -0,0 +1,350 @@
+import { mount } from '@vue/test-utils';
+import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui';
+import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue';
+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 Tracking from '~/tracking';
+import mockItems from './mocks/items.json';
+import mockFilters from './mocks/items_filters.json';
+
+const EmptyStateSlot = {
+ template: '<div class="empty-state">Empty State</div>',
+};
+
+const HeaderActionsSlot = {
+ template: '<div class="header-actions"><button>Action Button</button></div>',
+};
+
+const TitleSlot = {
+ template: '<div>Page Wrapper Title</div>',
+};
+
+const TableSlot = {
+ template: '<table class="gl-table"></table>',
+};
+
+const itemsCount = {
+ opened: 24,
+ closed: 10,
+ all: 34,
+};
+
+const ITEMS_STATUS_TABS = [
+ {
+ title: 'Opened items',
+ status: 'OPENED',
+ filters: ['opened'],
+ },
+ {
+ title: 'Closed items',
+ status: 'CLOSED',
+ filters: ['closed'],
+ },
+ {
+ title: 'All items',
+ status: 'ALL',
+ filters: ['all'],
+ },
+];
+
+describe('AlertManagementEmptyState', () => {
+ let wrapper;
+
+ function mountComponent({ props = {} } = {}) {
+ wrapper = mount(PageWrapper, {
+ provide: {
+ projectPath: '/link',
+ },
+ propsData: {
+ items: [],
+ itemsCount: {},
+ pageInfo: {},
+ statusTabs: [],
+ loading: false,
+ showItems: false,
+ showErrorMsg: false,
+ trackViewsOptions: {},
+ i18n: {},
+ serverErrorMessage: '',
+ filterSearchKey: '',
+ ...props,
+ },
+ slots: {
+ 'emtpy-state': EmptyStateSlot,
+ 'header-actions': HeaderActionsSlot,
+ title: TitleSlot,
+ table: TableSlot,
+ },
+ });
+ }
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ afterEach(() => {
+ if (wrapper) {
+ wrapper.destroy();
+ }
+ });
+
+ const EmptyState = () => wrapper.find('.empty-state');
+ const ItemsTable = () => wrapper.find('.gl-table');
+ const ErrorAlert = () => wrapper.find(GlAlert);
+ const Pagination = () => wrapper.find(GlPagination);
+ const Tabs = () => wrapper.find(GlTabs);
+ const ActionButton = () => wrapper.find('.header-actions > button');
+ const Filters = () => wrapper.find(FilteredSearchBar);
+ const findPagination = () => wrapper.find(GlPagination);
+ const findStatusFilterTabs = () => wrapper.findAll(GlTab);
+ const findStatusTabs = () => wrapper.find(GlTabs);
+ const findStatusFilterBadge = () => wrapper.findAll(GlBadge);
+
+ describe('Snowplow tracking', () => {
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ mountComponent({
+ props: { trackViewsOptions: { category: 'category', action: 'action' } },
+ });
+ });
+
+ it('should track the items list page views', () => {
+ const { category, action } = wrapper.vm.trackViewsOptions;
+ expect(Tracking.event).toHaveBeenCalledWith(category, action);
+ });
+ });
+
+ describe('Page wrapper with no items', () => {
+ it('renders the empty state if there are no items present', () => {
+ expect(EmptyState().exists()).toBe(true);
+ });
+ });
+
+ describe('Page wrapper with items', () => {
+ it('renders the tabs selection with valid tabs', () => {
+ mountComponent({
+ props: {
+ statusTabs: [{ status: 'opened', title: 'Open' }, { status: 'closed', title: 'Closed' }],
+ },
+ });
+
+ expect(Tabs().exists()).toBe(true);
+ });
+
+ it('renders the header action buttons if present', () => {
+ expect(ActionButton().exists()).toBe(true);
+ });
+
+ it('renders a error alert if there are errors', () => {
+ mountComponent({
+ props: { showErrorMsg: true },
+ });
+
+ expect(ErrorAlert().exists()).toBe(true);
+ });
+
+ it('renders a table of items if items are present', () => {
+ mountComponent({
+ props: { showItems: true, items: mockItems },
+ });
+
+ expect(ItemsTable().exists()).toBe(true);
+ });
+
+ it('renders pagination if there the pagination info object has a next or previous page', () => {
+ mountComponent({
+ props: { pageInfo: { hasNextPage: true } },
+ });
+
+ expect(Pagination().exists()).toBe(true);
+ });
+
+ it('renders the filter set with the tokens according to the prop filterSearchTokens', () => {
+ mountComponent({
+ props: { filterSearchTokens: ['assignee_username'] },
+ });
+
+ expect(Filters().exists()).toBe(true);
+ });
+ });
+
+ describe('Status Filter Tabs', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: { items: mockItems, itemsCount, statusTabs: ITEMS_STATUS_TABS },
+ });
+ });
+
+ it('should display filter tabs', () => {
+ const tabs = findStatusFilterTabs().wrappers;
+
+ tabs.forEach((tab, i) => {
+ expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
+ });
+ });
+
+ it('should display filter tabs with items count badge for each status', () => {
+ const tabs = findStatusFilterTabs().wrappers;
+ const badges = findStatusFilterBadge();
+
+ tabs.forEach((tab, i) => {
+ const status = ITEMS_STATUS_TABS[i].status.toLowerCase();
+ expect(tab.attributes('data-testid')).toContain(ITEMS_STATUS_TABS[i].status);
+ expect(badges.at(i).text()).toContain(itemsCount[status]);
+ });
+ });
+ });
+
+ describe('Pagination', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ items: mockItems,
+ itemsCount,
+ statusTabs: ITEMS_STATUS_TABS,
+ pageInfo: { hasNextPage: true },
+ },
+ });
+ });
+
+ it('should render pagination', () => {
+ expect(wrapper.find(GlPagination).exists()).toBe(true);
+ });
+
+ describe('prevPage', () => {
+ it('returns prevPage button', async () => {
+ findPagination().vm.$emit('input', 3);
+
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(0)
+ .text(),
+ ).toBe('Prev');
+ });
+
+ it('returns prevPage number', async () => {
+ findPagination().vm.$emit('input', 3);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.previousPage).toBe(2);
+ });
+
+ it('returns 0 when it is the first page', async () => {
+ findPagination().vm.$emit('input', 1);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.previousPage).toBe(0);
+ });
+ });
+
+ describe('nextPage', () => {
+ it('returns nextPage button', async () => {
+ findPagination().vm.$emit('input', 3);
+
+ await wrapper.vm.$nextTick();
+ expect(
+ findPagination()
+ .findAll('.page-item')
+ .at(1)
+ .text(),
+ ).toBe('Next');
+ });
+
+ it('returns nextPage number', async () => {
+ mountComponent({
+ props: {
+ items: mockItems,
+ itemsCount,
+ statusTabs: ITEMS_STATUS_TABS,
+ pageInfo: { hasNextPage: true },
+ },
+ });
+ findPagination().vm.$emit('input', 1);
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBe(2);
+ });
+
+ it('returns `null` when currentPage is already last page', async () => {
+ findStatusTabs().vm.$emit('input', 1);
+ findPagination().vm.$emit('input', 1);
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.nextPage).toBeNull();
+ });
+ });
+ });
+
+ describe('Filtered search component', () => {
+ beforeEach(() => {
+ mountComponent({
+ props: {
+ items: mockItems,
+ itemsCount,
+ statusTabs: ITEMS_STATUS_TABS,
+ filterSearchKey: 'items',
+ },
+ });
+ });
+
+ it('renders the search component for incidents', () => {
+ expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…');
+ expect(Filters().props('tokens')).toEqual([
+ {
+ type: 'author_username',
+ icon: 'user',
+ title: 'Author',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/link',
+ fetchAuthors: expect.any(Function),
+ },
+ {
+ type: 'assignee_username',
+ icon: 'user',
+ title: 'Assignee',
+ unique: true,
+ symbol: '@',
+ token: AuthorToken,
+ operators: [{ value: '=', description: 'is', default: 'true' }],
+ fetchPath: '/link',
+ fetchAuthors: expect.any(Function),
+ },
+ ]);
+ expect(Filters().props('recentSearchesStorageKey')).toBe('items');
+ });
+
+ it('returns correctly applied filter search values', async () => {
+ const searchTerm = 'foo';
+ wrapper.setData({
+ searchTerm,
+ });
+
+ await wrapper.vm.$nextTick();
+ expect(wrapper.vm.filteredSearchValue).toEqual([searchTerm]);
+ });
+
+ it('updates props tied to getIncidents GraphQL query', () => {
+ wrapper.vm.handleFilterItems(mockFilters);
+
+ expect(wrapper.vm.authorUsername).toBe('root');
+ expect(wrapper.vm.assigneeUsername).toEqual('root2');
+ expect(wrapper.vm.searchTerm).toBe(mockFilters[2].value.data);
+ });
+
+ it('updates props `searchTerm` and `authorUsername` with empty values when passed filters param is empty', () => {
+ wrapper.setData({
+ authorUsername: 'foo',
+ searchTerm: 'bar',
+ });
+
+ wrapper.vm.handleFilterItems([]);
+
+ expect(wrapper.vm.authorUsername).toBe('');
+ expect(wrapper.vm.searchTerm).toBe('');
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
index 16094a42668..ecea151fc8a 100644
--- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
+++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap
@@ -38,7 +38,8 @@ exports[`Package code instruction single line to match the default snapshot 1`]
data-testid="instruction-button"
>
<button
- class="btn input-group-text btn-secondary btn-md btn-default"
+ aria-label="Copy this value"
+ class="btn input-group-text btn-default btn-md gl-button btn-default-secondary btn-icon"
data-clipboard-text="npm i @my-package"
title="Copy npm install command"
type="button"
@@ -46,13 +47,15 @@ exports[`Package code instruction single line to match the default snapshot 1`]
<!---->
<svg
- class="gl-icon s16"
+ class="gl-button-icon gl-icon s16"
data-testid="copy-to-clipboard-icon"
>
<use
href="#copy-to-clipboard"
/>
</svg>
+
+ <!---->
</button>
</span>
</div>
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 e2cfdedb4bf..2a48bf4f2d6 100644
--- a/spec/frontend/vue_shared/components/registry/list_item_spec.js
+++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js
@@ -58,9 +58,9 @@ describe('list item', () => {
describe.each`
slotNames
- ${['details_foo']}
- ${['details_foo', 'details_bar']}
- ${['details_foo', 'details_bar', 'details_baz']}
+ ${['details-foo']}
+ ${['details-foo', 'details-bar']}
+ ${['details-foo', 'details-bar', 'details-baz']}
`('$slotNames details slots', ({ slotNames }) => {
const slotMocks = slotNames.reduce((acc, current) => {
acc[current] = `<div data-testid="${current}" />`;
@@ -89,7 +89,7 @@ describe('list item', () => {
describe('details toggle button', () => {
it('is visible when at least one details slot exists', async () => {
- mountComponent({}, { details_foo: '<span></span>' });
+ mountComponent({}, { 'details-foo': '<span></span>' });
await wrapper.vm.$nextTick();
expect(findToggleDetailsButton().exists()).toBe(true);
});
diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js
index 6740d6097a4..5cb606b58d9 100644
--- a/spec/frontend/vue_shared/components/registry/title_area_spec.js
+++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js
@@ -1,4 +1,4 @@
-import { GlAvatar } from '@gitlab/ui';
+import { GlAvatar, GlSprintf, GlLink } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import component from '~/vue_shared/components/registry/title_area.vue';
@@ -10,10 +10,12 @@ describe('title area', () => {
const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`);
const findTitle = () => wrapper.find('[data-testid="title"]');
const findAvatar = () => wrapper.find(GlAvatar);
+ const findInfoMessages = () => wrapper.findAll('[data-testid="info-message"]');
const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => {
wrapper = shallowMount(component, {
propsData,
+ stubs: { GlSprintf },
slots: {
'sub-header': '<div data-testid="sub-header" />',
'right-actions': '<div data-testid="right-actions" />',
@@ -77,9 +79,9 @@ describe('title area', () => {
describe.each`
slotNames
- ${['metadata_foo']}
- ${['metadata_foo', 'metadata_bar']}
- ${['metadata_foo', 'metadata_bar', 'metadata_baz']}
+ ${['metadata-foo']}
+ ${['metadata-foo', 'metadata-bar']}
+ ${['metadata-foo', 'metadata-bar', 'metadata-baz']}
`('$slotNames metadata slots', ({ slotNames }) => {
const slotMocks = slotNames.reduce((acc, current) => {
acc[current] = `<div data-testid="${current}" />`;
@@ -95,4 +97,33 @@ describe('title area', () => {
});
});
});
+
+ describe('info-messages', () => {
+ it('shows a message when the props contains one', () => {
+ mountComponent({ propsData: { infoMessages: [{ text: 'foo foo bar bar' }] } });
+
+ const messages = findInfoMessages();
+ expect(messages).toHaveLength(1);
+ expect(messages.at(0).text()).toBe('foo foo bar bar');
+ });
+
+ it('shows a link when the props contains one', () => {
+ mountComponent({
+ propsData: {
+ infoMessages: [{ text: 'foo %{docLinkStart}link%{docLinkEnd}', link: 'bar' }],
+ },
+ });
+
+ const message = findInfoMessages().at(0);
+
+ expect(message.find(GlLink).attributes('href')).toBe('bar');
+ expect(message.text()).toBe('foo link');
+ });
+
+ it('multiple messages generates multiple spans', () => {
+ mountComponent({ propsData: { infoMessages: [{ text: 'foo' }, { text: 'bar' }] } });
+
+ expect(findInfoMessages()).toHaveLength(2);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
index 16f60b5ff21..0f2f263a776 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js
@@ -4,24 +4,37 @@ import {
removeCustomEventListener,
registerHTMLToMarkdownRenderer,
addImage,
+ insertVideo,
getMarkdown,
getEditorOptions,
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer';
import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer';
+import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer');
jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer');
+jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html');
describe('Editor Service', () => {
let mockInstance;
let event;
let handler;
+ const parseHtml = str => {
+ const wrapper = document.createElement('div');
+ wrapper.innerHTML = str;
+ return wrapper.firstChild;
+ };
beforeEach(() => {
mockInstance = {
eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() },
- editor: { exec: jest.fn() },
+ editor: {
+ exec: jest.fn(),
+ isWysiwygMode: jest.fn(),
+ getSquire: jest.fn(),
+ insertText: jest.fn(),
+ },
invoke: jest.fn(),
toMarkOptions: {
renderer: {
@@ -87,6 +100,38 @@ describe('Editor Service', () => {
});
});
+ describe('insertVideo', () => {
+ const mockUrl = 'some/url';
+ const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`;
+ const mockInsertElement = jest.fn();
+
+ beforeEach(() =>
+ mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }),
+ );
+
+ describe('WYSIWYG mode', () => {
+ it('calls the insertElement method on the squire instance with an iFrame element', () => {
+ mockInstance.editor.isWysiwygMode.mockReturnValue(true);
+
+ insertVideo(mockInstance, mockUrl);
+
+ expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith(
+ parseHtml(htmlString),
+ );
+ });
+ });
+
+ describe('Markdown mode', () => {
+ it('calls the insertText method on the editor instance with the iFrame element HTML', () => {
+ mockInstance.editor.isWysiwygMode.mockReturnValue(false);
+
+ insertVideo(mockInstance, mockUrl);
+
+ expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString);
+ });
+ });
+ });
+
describe('getMarkdown', () => {
it('calls the invoke method on the instance', () => {
getMarkdown(mockInstance);
@@ -143,5 +188,14 @@ describe('Editor Service', () => {
getEditorOptions(externalOptions);
expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers);
});
+
+ it('uses the internal sanitizeHTML service for HTML sanitization', () => {
+ const options = getEditorOptions();
+ const html = '<div></div>';
+
+ options.customHTMLSanitizer(html);
+
+ expect(sanitizeHTML).toHaveBeenCalledWith(html);
+ });
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js
new file mode 100644
index 00000000000..be3a4030b1d
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js
@@ -0,0 +1,44 @@
+import { shallowMount } from '@vue/test-utils';
+import { GlModal } from '@gitlab/ui';
+import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
+
+describe('Insert Video Modal', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.find(GlModal);
+ const findUrlInput = () => wrapper.find({ ref: 'urlInput' });
+
+ const triggerInsertVideo = url => {
+ const preventDefault = jest.fn();
+ findUrlInput().vm.$emit('input', url);
+ findModal().vm.$emit('primary', { preventDefault });
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(InsertVideoModal);
+ });
+
+ afterEach(() => wrapper.destroy());
+
+ describe('when content is loaded', () => {
+ it('renders a modal component', () => {
+ expect(findModal().exists()).toBe(true);
+ });
+
+ it('renders an input to add a URL', () => {
+ expect(findUrlInput().exists()).toBe(true);
+ });
+ });
+
+ describe('insert video', () => {
+ it.each`
+ url | emitted
+ ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]}
+ ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]}
+ ${'::youtube.com/invalid/url'} | ${undefined}
+ `('formats the url correctly', ({ url, emitted }) => {
+ triggerInsertVideo(url);
+ expect(wrapper.emitted('insertVideo')).toEqual(emitted);
+ });
+ });
+});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
index 3d54db7fe5c..8c2c0413819 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js
@@ -1,6 +1,7 @@
import { shallowMount } from '@vue/test-utils';
import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue';
import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue';
+import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue';
import {
EDITOR_TYPES,
EDITOR_HEIGHT,
@@ -12,6 +13,7 @@ import {
addCustomEventListener,
removeCustomEventListener,
addImage,
+ insertVideo,
registerHTMLToMarkdownRenderer,
getEditorOptions,
} from '~/vue_shared/components/rich_content_editor/services/editor_service';
@@ -21,6 +23,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service',
addCustomEventListener: jest.fn(),
removeCustomEventListener: jest.fn(),
addImage: jest.fn(),
+ insertVideo: jest.fn(),
registerHTMLToMarkdownRenderer: jest.fn(),
getEditorOptions: jest.fn(),
}));
@@ -32,6 +35,7 @@ describe('Rich Content Editor', () => {
const imageRoot = 'path/to/root/';
const findEditor = () => wrapper.find({ ref: 'editor' });
const findAddImageModal = () => wrapper.find(AddImageModal);
+ const findInsertVideoModal = () => wrapper.find(InsertVideoModal);
const buildWrapper = () => {
wrapper = shallowMount(RichContentEditor, {
@@ -122,6 +126,14 @@ describe('Rich Content Editor', () => {
);
});
+ it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
+ expect(addCustomEventListener).toHaveBeenCalledWith(
+ wrapper.vm.editorApi,
+ CUSTOM_EVENTS.openInsertVideoModal,
+ wrapper.vm.onOpenInsertVideoModal,
+ );
+ });
+
it('registers HTML to markdown renderer', () => {
expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi);
});
@@ -141,6 +153,16 @@ describe('Rich Content Editor', () => {
wrapper.vm.onOpenAddImageModal,
);
});
+
+ it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => {
+ wrapper.vm.$destroy();
+
+ expect(removeCustomEventListener).toHaveBeenCalledWith(
+ wrapper.vm.editorApi,
+ CUSTOM_EVENTS.openInsertVideoModal,
+ wrapper.vm.onOpenInsertVideoModal,
+ );
+ });
});
describe('add image modal', () => {
@@ -161,4 +183,23 @@ describe('Rich Content Editor', () => {
expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage);
});
});
+
+ describe('insert video modal', () => {
+ beforeEach(() => {
+ buildWrapper();
+ });
+
+ it('renders an insertVideoModal component', () => {
+ expect(findInsertVideoModal().exists()).toBe(true);
+ });
+
+ it('calls the onInsertVideo method when the insertVideo event is emitted', () => {
+ const mockUrl = 'https://www.youtube.com/embed/someId';
+ const mockInstance = { exec: jest.fn() };
+ wrapper.vm.$refs.editor = mockInstance;
+
+ findInsertVideoModal().vm.$emit('insertVideo', mockUrl);
+ expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
index a6c712eeb31..b31684a400e 100644
--- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js
@@ -1,22 +1,21 @@
import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block';
import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token';
-import { normalTextNode } from './mock_data';
+describe('rich_content_editor/services/renderers/render_html_block', () => {
+ const htmlBlockNode = {
+ literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
+ type: 'htmlBlock',
+ };
-const htmlBlockNode = {
- firstChild: null,
- literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>',
- type: 'htmlBlock',
-};
-
-describe('Render HTML renderer', () => {
describe('canRender', () => {
- it('should return true when the argument is an html block', () => {
- expect(renderer.canRender(htmlBlockNode)).toBe(true);
- });
-
- it('should return false when the argument is not an html block', () => {
- expect(renderer.canRender(normalTextNode)).toBe(false);
+ it.each`
+ input | result
+ ${htmlBlockNode} | ${true}
+ ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true}
+ ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false}
+ ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false}
+ `('returns $result when input=$input', ({ input, result }) => {
+ expect(renderer.canRender(input)).toBe(result);
});
});
diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js
new file mode 100644
index 00000000000..f2182ef60d7
--- /dev/null
+++ b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js
@@ -0,0 +1,11 @@
+import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html';
+
+describe('rich_content_editor/services/sanitize_html', () => {
+ it.each`
+ input | result
+ ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'}
+ ${'<iframe src="https://gitlab.com"></iframe>'} | ${''}
+ `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => {
+ expect(sanitizeHTML(input)).toBe(result);
+ });
+});
diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js
index 31316a93ecd..240d6cb5a34 100644
--- a/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/collapsed_calendar_icon_spec.js
@@ -18,7 +18,7 @@ describe('collapsedCalendarIcon', () => {
});
it('should hide calendar icon if showIcon', () => {
- expect(vm.$el.querySelector('.fa-calendar')).toBeNull();
+ expect(vm.$el.querySelector('[data-testid="calendar-icon"]')).toBeNull();
});
it('should render text', () => {
diff --git a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
index 65255968bc7..08fc822577e 100644
--- a/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/collapsed_grouped_date_picker_spec.js
@@ -80,7 +80,7 @@ describe('collapsedGroupedDatePicker', () => {
it('should have tooltip as `Start and due date`', () => {
const icons = vm.$el.querySelectorAll('.sidebar-collapsed-icon');
- expect(icons[0].dataset.originalTitle).toBe('Start and due date');
+ expect(icons[0].title).toBe('Start and due date');
});
});
});
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
index 589be0ad7a4..a9350bc059d 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js
@@ -69,6 +69,16 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
});
+ it('returns matching labels with fuzzy filtering', () => {
+ wrapper.setData({
+ searchKey: 'bg',
+ });
+
+ expect(wrapper.vm.visibleLabels.length).toBe(2);
+ expect(wrapper.vm.visibleLabels[0].title).toBe('Bug');
+ expect(wrapper.vm.visibleLabels[1].title).toBe('Boog');
+ });
+
it('returns all labels when `searchKey` is empty', () => {
wrapper.setData({
searchKey: '',
@@ -133,6 +143,19 @@ describe('DropdownContentsLabelsView', () => {
expect(wrapper.vm.currentHighlightItem).toBe(2);
});
+ it('resets the search text when the Enter key is pressed', () => {
+ wrapper.setData({
+ currentHighlightItem: 1,
+ searchKey: 'bug',
+ });
+
+ wrapper.vm.handleKeyDown({
+ keyCode: ENTER_KEY_CODE,
+ });
+
+ expect(wrapper.vm.searchKey).toBe('');
+ });
+
it('calls action `updateSelectedLabels` with currently highlighted label when Enter key is pressed', () => {
jest.spyOn(wrapper.vm, 'updateSelectedLabels').mockImplementation();
wrapper.setData({
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
index e1008d13fc2..9697d6c30f2 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js
@@ -24,6 +24,13 @@ export const mockLabels = [
color: '#FF0000',
textColor: '#FFFFFF',
},
+ {
+ id: 29,
+ title: 'Boog',
+ description: 'Label for bugs',
+ color: '#FF0000',
+ textColor: '#FFFFFF',
+ },
];
export const mockConfig = {
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
index bfb8e263d81..c742220ba8a 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js
@@ -259,21 +259,6 @@ describe('LabelsSelect Actions', () => {
});
});
- describe('replaceSelectedLabels', () => {
- it('replaces `state.selectedLabels`', done => {
- const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
-
- testAction(
- actions.replaceSelectedLabels,
- selectedLabels,
- state,
- [{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }],
- [],
- done,
- );
- });
- });
-
describe('updateSelectedLabels', () => {
it('updates `state.labels` based on provided `labels` param', done => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
index 3414eec8a63..8081806e314 100644
--- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js
@@ -152,19 +152,6 @@ describe('LabelsSelect Mutations', () => {
});
});
- describe(`${types.REPLACE_SELECTED_LABELS}`, () => {
- it('replaces `state.selectedLabels`', () => {
- const state = {
- selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }],
- };
- const newSelectedLabels = [{ id: 2 }, { id: 5 }];
-
- mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels);
-
- expect(state.selectedLabels).toEqual(newSelectedLabels);
- });
- });
-
describe(`${types.UPDATE_SELECTED_LABELS}`, () => {
const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }];
diff --git a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js
index 4342f5e2105..f1c3e8a1ddc 100644
--- a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js
+++ b/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js
@@ -11,15 +11,14 @@ describe('toggleSidebar', () => {
});
});
- it('should render << when collapsed', () => {
- expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-left')).toEqual(true);
+ it('should render the "chevron-double-lg-left" icon when collapsed', () => {
+ expect(vm.$el.querySelector('[data-testid="chevron-double-lg-left-icon"]')).not.toBeNull();
});
- it('should render >> when collapsed', () => {
+ it('should render the "chevron-double-lg-right" icon when expanded', async () => {
vm.collapsed = false;
- Vue.nextTick(() => {
- expect(vm.$el.querySelector('.fa').classList.contains('fa-angle-double-right')).toEqual(true);
- });
+ await Vue.nextTick();
+ expect(vm.$el.querySelector('[data-testid="chevron-double-lg-right-icon"]')).not.toBeNull();
});
it('should emit toggle event when button clicked', () => {
diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js
index f3bd4c14717..e09bc073042 100644
--- a/spec/frontend/vue_shared/components/split_button_spec.js
+++ b/spec/frontend/vue_shared/components/split_button_spec.js
@@ -1,4 +1,4 @@
-import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui';
+import { GlDropdown, GlDropdownItem } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import SplitButton from '~/vue_shared/components/split_button.vue';
@@ -25,10 +25,10 @@ describe('SplitButton', () => {
});
};
- const findDropdown = () => wrapper.find(GlDeprecatedDropdown);
+ const findDropdown = () => wrapper.find(GlDropdown);
const findDropdownItem = (index = 0) =>
findDropdown()
- .findAll(GlDeprecatedDropdownItem)
+ .findAll(GlDropdownItem)
.at(index);
const selectItem = index => {
findDropdownItem(index).vm.$emit('click');
diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js
index 482b5de11f6..1f8a214d632 100644
--- a/spec/frontend/vue_shared/components/todo_button_spec.js
+++ b/spec/frontend/vue_shared/components/todo_button_spec.js
@@ -33,7 +33,7 @@ describe('Todo Button', () => {
it.each`
label | isTodo
${'Mark as done'} | ${true}
- ${'Add a To-Do'} | ${false}
+ ${'Add a To Do'} | ${false}
`('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => {
createComponent({ isTodo });
diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
index b43bb6b10e0..c208d7b0226 100644
--- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
+++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js
@@ -21,6 +21,9 @@ describe('User Popover Component', () => {
let wrapper;
beforeEach(() => {
+ window.gon.features = {
+ securityAutoFix: true,
+ };
loadFixtures(fixtureTemplate);
});
@@ -28,6 +31,7 @@ describe('User Popover Component', () => {
wrapper.destroy();
});
+ const findByTestId = testid => wrapper.find(`[data-testid="${testid}"]`);
const findUserStatus = () => wrapper.find('.js-user-status');
const findTarget = () => document.querySelector('.js-user-link');
@@ -196,4 +200,30 @@ describe('User Popover Component', () => {
expect(findUserStatus().exists()).toBe(false);
});
});
+
+ describe('security bot', () => {
+ const SECURITY_BOT_USER = {
+ ...DEFAULT_PROPS.user,
+ name: 'GitLab Security Bot',
+ username: 'GitLab-Security-Bot',
+ websiteUrl: '/security/bot/docs',
+ };
+ const findSecurityBotDocsLink = () => findByTestId('user-popover-bot-docs-link');
+
+ it("shows a link to the bot's documentation", () => {
+ createWrapper({ user: SECURITY_BOT_USER });
+ const securityBotDocsLink = findSecurityBotDocsLink();
+ expect(securityBotDocsLink.exists()).toBe(true);
+ expect(securityBotDocsLink.attributes('href')).toBe(SECURITY_BOT_USER.websiteUrl);
+ });
+
+ it('does not show the link if the feature flag is disabled', () => {
+ window.gon.features = {
+ securityAutoFix: false,
+ };
+ createWrapper({ user: SECURITY_BOT_USER });
+
+ expect(findSecurityBotDocsLink().exists()).toBe(false);
+ });
+ });
});
diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js
index 57f511903d9..8ed072bed13 100644
--- a/spec/frontend/vue_shared/components/web_ide_link_spec.js
+++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js
@@ -3,9 +3,27 @@ import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue';
import WebIdeLink from '~/vue_shared/components/web_ide_link.vue';
import ActionsButton from '~/vue_shared/components/actions_button.vue';
+const TEST_EDIT_URL = '/gitlab-test/test/-/edit/master/';
const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/';
const TEST_GITPOD_URL = 'https://gitpod.test/';
+const ACTION_EDIT = {
+ href: TEST_EDIT_URL,
+ key: 'edit',
+ text: 'Edit',
+ secondaryText: 'Edit this file only.',
+ tooltip: '',
+ attrs: {
+ 'data-qa-selector': 'edit_button',
+ 'data-track-event': 'click_edit',
+ 'data-track-label': 'Edit',
+ },
+};
+const ACTION_EDIT_CONFIRM_FORK = {
+ ...ACTION_EDIT,
+ href: '#modal-confirm-fork-edit',
+ handle: expect.any(Function),
+};
const ACTION_WEB_IDE = {
href: TEST_WEB_IDE_URL,
key: 'webide',
@@ -14,13 +32,16 @@ const ACTION_WEB_IDE = {
text: 'Web IDE',
attrs: {
'data-qa-selector': 'web_ide_button',
+ 'data-track-event': 'click_edit_ide',
+ 'data-track-label': 'Web IDE',
},
};
-const ACTION_WEB_IDE_FORK = {
+const ACTION_WEB_IDE_CONFIRM_FORK = {
...ACTION_WEB_IDE,
- href: '#modal-confirm-fork',
+ href: '#modal-confirm-fork-webide',
handle: expect.any(Function),
};
+const ACTION_WEB_IDE_EDIT_FORK = { ...ACTION_WEB_IDE, text: 'Edit fork in Web IDE' };
const ACTION_GITPOD = {
href: TEST_GITPOD_URL,
key: 'gitpod',
@@ -43,6 +64,7 @@ describe('Web IDE link component', () => {
function createComponent(props) {
wrapper = shallowMount(WebIdeLink, {
propsData: {
+ editUrl: TEST_EDIT_URL,
webIdeUrl: TEST_WEB_IDE_URL,
gitpodUrl: TEST_GITPOD_URL,
...props,
@@ -57,14 +79,36 @@ describe('Web IDE link component', () => {
const findActionsButton = () => wrapper.find(ActionsButton);
const findLocalStorageSync = () => wrapper.find(LocalStorageSync);
- it.each`
- props | expectedActions
- ${{}} | ${[ACTION_WEB_IDE]}
- ${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]}
- ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]}
- ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]}
- ${{ showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_WEB_IDE, ACTION_GITPOD_ENABLE]}
- `('renders actions with props=$props', ({ props, expectedActions }) => {
+ it.each([
+ {
+ props: {},
+ expectedActions: [ACTION_WEB_IDE, ACTION_EDIT],
+ },
+ {
+ props: { isFork: true },
+ expectedActions: [ACTION_WEB_IDE_EDIT_FORK, ACTION_EDIT],
+ },
+ {
+ props: { needsToFork: true },
+ expectedActions: [ACTION_WEB_IDE_CONFIRM_FORK, ACTION_EDIT_CONFIRM_FORK],
+ },
+ {
+ props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true },
+ expectedActions: [ACTION_EDIT, ACTION_GITPOD],
+ },
+ {
+ props: { showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false },
+ expectedActions: [ACTION_EDIT, ACTION_GITPOD_ENABLE],
+ },
+ {
+ props: { showGitpodButton: true, gitpodEnabled: false },
+ expectedActions: [ACTION_WEB_IDE, ACTION_EDIT, ACTION_GITPOD_ENABLE],
+ },
+ {
+ props: { showEditButton: false },
+ expectedActions: [ACTION_WEB_IDE],
+ },
+ ])('renders actions with appropriately for given props', ({ props, expectedActions }) => {
createComponent(props);
expect(findActionsButton().props('actions')).toEqual(expectedActions);
@@ -72,7 +116,12 @@ describe('Web IDE link component', () => {
describe('with multiple actions', () => {
beforeEach(() => {
- createComponent({ showWebIdeButton: true, showGitpodButton: true, gitpodEnabled: true });
+ createComponent({
+ showEditButton: false,
+ showWebIdeButton: true,
+ showGitpodButton: true,
+ gitpodEnabled: true,
+ });
});
it('selected Web IDE by default', () => {
diff --git a/spec/frontend/vue_shared/directives/tooltip_spec.js b/spec/frontend/vue_shared/directives/tooltip_spec.js
index 9d3dd3c5f75..4217b8d3c02 100644
--- a/spec/frontend/vue_shared/directives/tooltip_spec.js
+++ b/spec/frontend/vue_shared/directives/tooltip_spec.js
@@ -1,42 +1,59 @@
import $ from 'jquery';
+import { escape } from 'lodash';
import { mount } from '@vue/test-utils';
import tooltip from '~/vue_shared/directives/tooltip';
+const DEFAULT_TOOLTIP_TEMPLATE = '<div v-tooltip :title="tooltip"></div>';
+const HTML_TOOLTIP_TEMPLATE = '<div v-tooltip data-html="true" :title="tooltip"></div>';
+
describe('Tooltip directive', () => {
- let vm;
+ let wrapper;
+
+ function createTooltipContainer({
+ template = DEFAULT_TOOLTIP_TEMPLATE,
+ text = 'some text',
+ } = {}) {
+ wrapper = mount(
+ {
+ directives: { tooltip },
+ data: () => ({ tooltip: text }),
+ template,
+ },
+ { attachToDocument: true },
+ );
+ }
+
+ async function showTooltip() {
+ $(wrapper.vm.$el).tooltip('show');
+ jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+ }
+
+ function findTooltipInnerHtml() {
+ return document.querySelector('.tooltip-inner').innerHTML;
+ }
+
+ function findTooltipHtml() {
+ return document.querySelector('.tooltip').innerHTML;
+ }
afterEach(() => {
- if (vm) {
- vm.$destroy();
- }
+ wrapper.destroy();
+ wrapper = null;
});
describe('with a single tooltip', () => {
- beforeEach(() => {
- const wrapper = mount(
- {
- directives: {
- tooltip,
- },
- data() {
- return {
- tooltip: 'some text',
- };
- },
- template: '<div v-tooltip :title="tooltip"></div>',
- },
- { attachToDocument: true },
- );
-
- vm = wrapper.vm;
- });
-
it('should have tooltip plugin applied', () => {
- expect($(vm.$el).data('bs.tooltip')).toBeDefined();
+ createTooltipContainer();
+
+ expect($(wrapper.vm.$el).data('bs.tooltip')).toBeDefined();
});
it('displays the title as tooltip', () => {
- $(vm.$el).tooltip('show');
+ createTooltipContainer();
+
+ $(wrapper.vm.$el).tooltip('show');
+
jest.runOnlyPendingTimers();
const tooltipElement = document.querySelector('.tooltip-inner');
@@ -44,52 +61,98 @@ describe('Tooltip directive', () => {
expect(tooltipElement.textContent).toContain('some text');
});
- it('updates a visible tooltip', () => {
- $(vm.$el).tooltip('show');
+ it.each`
+ condition | template | sanitize
+ ${'does not contain any html'} | ${DEFAULT_TOOLTIP_TEMPLATE} | ${false}
+ ${'contains html'} | ${HTML_TOOLTIP_TEMPLATE} | ${true}
+ `('passes sanitize=$sanitize if the tooltip $condition', ({ template, sanitize }) => {
+ createTooltipContainer({ template });
+
+ expect($(wrapper.vm.$el).data('bs.tooltip').config.sanitize).toEqual(sanitize);
+ });
+
+ it('updates a visible tooltip', async () => {
+ createTooltipContainer();
+
+ $(wrapper.vm.$el).tooltip('show');
jest.runOnlyPendingTimers();
const tooltipElement = document.querySelector('.tooltip-inner');
- vm.tooltip = 'other text';
+ wrapper.vm.tooltip = 'other text';
jest.runOnlyPendingTimers();
+ await wrapper.vm.$nextTick();
+
+ expect(tooltipElement.textContent).toContain('other text');
+ });
+
+ describe('tooltip sanitization', () => {
+ it('reads tooltip content as text if data-html is not passed', async () => {
+ createTooltipContainer({ text: 'sample text<script>alert("XSS!!")</script>' });
- return vm.$nextTick().then(() => {
- expect(tooltipElement.textContent).toContain('other text');
+ await showTooltip();
+
+ const result = findTooltipInnerHtml();
+ expect(result).toEqual('sample text&lt;script&gt;alert("XSS!!")&lt;/script&gt;');
+ });
+
+ it('sanitizes tooltip if data-html is passed', async () => {
+ createTooltipContainer({
+ template: HTML_TOOLTIP_TEMPLATE,
+ text: 'sample text<script>alert("XSS!!")</script>',
+ });
+
+ await showTooltip();
+
+ const result = findTooltipInnerHtml();
+ expect(result).toEqual('sample text');
+ expect(result).not.toContain('XSS!!');
+ });
+
+ it('sanitizes tooltip if data-template is passed', async () => {
+ const tooltipTemplate = escape(
+ '<div class="tooltip" role="tooltip"><div onclick="alert(\'XSS!\')" class="arrow"></div><div class="tooltip-inner"></div></div>',
+ );
+
+ createTooltipContainer({
+ template: `<div v-tooltip :title="tooltip" data-html="false" data-template="${tooltipTemplate}"></div>`,
+ });
+
+ await showTooltip();
+
+ const result = findTooltipHtml();
+ expect(result).toEqual(
+ // objectionable element is removed
+ '<div class="arrow"></div><div class="tooltip-inner">some text</div>',
+ );
+ expect(result).not.toContain('XSS!!');
});
});
});
describe('with multiple tooltips', () => {
beforeEach(() => {
- const wrapper = mount(
- {
- directives: {
- tooltip,
- },
- template: `
- <div>
- <div
- v-tooltip
- class="js-look-for-tooltip"
- title="foo">
- </div>
- <div
- v-tooltip
- title="bar">
- </div>
+ createTooltipContainer({
+ template: `
+ <div>
+ <div
+ v-tooltip
+ class="js-look-for-tooltip"
+ title="foo">
</div>
- `,
- },
- { attachToDocument: true },
- );
-
- vm = wrapper.vm;
+ <div
+ v-tooltip
+ title="bar">
+ </div>
+ </div>
+ `,
+ });
});
it('should have tooltip plugin applied to all instances', () => {
expect(
- $(vm.$el)
+ $(wrapper.vm.$el)
.find('.js-look-for-tooltip')
.data('bs.tooltip'),
).toBeDefined();
diff --git a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js b/spec/frontend/vue_shared/droplab_dropdown_button_spec.js
deleted file mode 100644
index e57c730ecee..00000000000
--- a/spec/frontend/vue_shared/droplab_dropdown_button_spec.js
+++ /dev/null
@@ -1,132 +0,0 @@
-import { mount } from '@vue/test-utils';
-
-import DroplabDropdownButton from '~/vue_shared/components/droplab_dropdown_button.vue';
-
-const mockActions = [
- {
- title: 'Foo',
- description: 'Some foo action',
- },
- {
- title: 'Bar',
- description: 'Some bar action',
- },
-];
-
-const createComponent = ({
- size = '',
- dropdownClass = '',
- actions = mockActions,
- defaultAction = 0,
-}) =>
- mount(DroplabDropdownButton, {
- propsData: {
- size,
- dropdownClass,
- actions,
- defaultAction,
- },
- });
-
-describe('DroplabDropdownButton', () => {
- let wrapper;
-
- beforeEach(() => {
- wrapper = createComponent({});
- });
-
- afterEach(() => {
- wrapper.destroy();
- });
-
- describe('data', () => {
- it('contains `selectedAction` representing value of `defaultAction` prop', () => {
- expect(wrapper.vm.selectedAction).toBe(0);
- });
- });
-
- describe('computed', () => {
- describe('selectedActionTitle', () => {
- it('returns string containing title of selected action', () => {
- wrapper.setData({ selectedAction: 0 });
-
- expect(wrapper.vm.selectedActionTitle).toBe(mockActions[0].title);
-
- wrapper.setData({ selectedAction: 1 });
-
- expect(wrapper.vm.selectedActionTitle).toBe(mockActions[1].title);
- });
- });
-
- describe('buttonSizeClass', () => {
- it('returns string containing button sizing class based on `size` prop', done => {
- const wrapperWithSize = createComponent({
- size: 'sm',
- });
-
- wrapperWithSize.vm.$nextTick(() => {
- expect(wrapperWithSize.vm.buttonSizeClass).toBe('btn-sm');
-
- done();
- wrapperWithSize.destroy();
- });
- });
- });
- });
-
- describe('methods', () => {
- describe('handlePrimaryActionClick', () => {
- it('emits `onActionClick` event on component with selectedAction object as param', () => {
- jest.spyOn(wrapper.vm, '$emit');
-
- wrapper.setData({ selectedAction: 0 });
- wrapper.vm.handlePrimaryActionClick();
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionClick', mockActions[0]);
- });
- });
-
- describe('handleActionClick', () => {
- it('emits `onActionSelect` event on component with selectedAction index as param', () => {
- jest.spyOn(wrapper.vm, '$emit');
-
- wrapper.vm.handleActionClick(1);
-
- expect(wrapper.vm.$emit).toHaveBeenCalledWith('onActionSelect', 1);
- });
- });
- });
-
- describe('template', () => {
- it('renders default action button', () => {
- const defaultButton = wrapper.findAll('.btn').at(0);
-
- expect(defaultButton.text()).toBe(mockActions[0].title);
- });
-
- it('renders dropdown button', () => {
- const dropdownButton = wrapper.findAll('.dropdown-toggle').at(0);
-
- expect(dropdownButton.isVisible()).toBe(true);
- });
-
- it('renders dropdown actions', () => {
- const dropdownActions = wrapper.findAll('.dropdown-menu li button');
-
- Array(dropdownActions.length)
- .fill()
- .forEach((_, index) => {
- const actionContent = dropdownActions.at(index).find('.description');
-
- expect(actionContent.find('strong').text()).toBe(mockActions[index].title);
- expect(actionContent.find('p').text()).toBe(mockActions[index].description);
- });
- });
-
- it('renders divider between dropdown actions', () => {
- const dropdownDivider = wrapper.find('.dropdown-menu .divider');
-
- expect(dropdownDivider.isVisible()).toBe(true);
- });
- });
-});
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
new file mode 100644
index 00000000000..31bdfc931ac
--- /dev/null
+++ b/spec/frontend/vue_shared/security_reports/security_reports_app_spec.js
@@ -0,0 +1,118 @@
+import { mount } from '@vue/test-utils';
+import Api from '~/api';
+import Flash from '~/flash';
+import SecurityReportsApp from '~/vue_shared/security_reports/security_reports_app.vue';
+
+jest.mock('~/flash');
+
+describe('Grouped security reports app', () => {
+ let wrapper;
+ let mrTabsMock;
+
+ const props = {
+ pipelineId: 123,
+ projectId: 456,
+ securityReportsDocsPath: '/docs',
+ };
+
+ const createComponent = () => {
+ wrapper = mount(SecurityReportsApp, {
+ propsData: { ...props },
+ });
+ };
+
+ const findPipelinesTabAnchor = () => wrapper.find('[data-testid="show-pipelines"]');
+ const findHelpLink = () => wrapper.find('[data-testid="help"]');
+ const setupMrTabsMock = () => {
+ mrTabsMock = { tabShown: jest.fn() };
+ window.mrTabs = mrTabsMock;
+ };
+ const setupMockJobArtifact = reportType => {
+ jest
+ .spyOn(Api, 'pipelineJobs')
+ .mockResolvedValue({ data: [{ artifacts: [{ file_type: reportType }] }] });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ delete window.mrTabs;
+ });
+
+ describe.each(SecurityReportsApp.reportTypes)('given a report type %p', reportType => {
+ beforeEach(() => {
+ window.mrTabs = { tabShown: jest.fn() };
+ setupMockJobArtifact(reportType);
+ createComponent();
+ });
+
+ it('calls the pipelineJobs API correctly', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
+ });
+
+ it('renders the expected message', () => {
+ expect(wrapper.text()).toMatchInterpolatedText(SecurityReportsApp.i18n.scansHaveRun);
+ });
+
+ describe('clicking the anchor to the pipelines tab', () => {
+ beforeEach(() => {
+ setupMrTabsMock();
+ findPipelinesTabAnchor().trigger('click');
+ });
+
+ it('calls the mrTabs.tabShown global', () => {
+ expect(mrTabsMock.tabShown.mock.calls).toEqual([['pipelines']]);
+ });
+ });
+
+ it('renders a help link', () => {
+ expect(findHelpLink().attributes()).toMatchObject({
+ href: props.securityReportsDocsPath,
+ });
+ });
+ });
+
+ describe('given a report type "foo"', () => {
+ beforeEach(() => {
+ setupMockJobArtifact('foo');
+ createComponent();
+ });
+
+ it('calls the pipelineJobs API correctly', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
+ });
+
+ it('renders nothing', () => {
+ expect(wrapper.html()).toBe('');
+ });
+ });
+
+ describe('given an error from the API', () => {
+ let error;
+
+ beforeEach(() => {
+ error = new Error('an error');
+ jest.spyOn(Api, 'pipelineJobs').mockRejectedValue(error);
+ createComponent();
+ });
+
+ it('calls the pipelineJobs API correctly', () => {
+ expect(Api.pipelineJobs).toHaveBeenCalledWith(props.projectId, props.pipelineId);
+ });
+
+ it('renders nothing', () => {
+ expect(wrapper.html()).toBe('');
+ });
+
+ it('calls Flash correctly', () => {
+ expect(Flash.mock.calls).toEqual([
+ [
+ {
+ message: SecurityReportsApp.i18n.apiError,
+ captureError: true,
+ error,
+ },
+ ],
+ ]);
+ });
+ });
+});