diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 10:08:36 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2020-10-21 10:08:36 +0300 |
commit | 48aff82709769b098321c738f3444b9bdaa694c6 (patch) | |
tree | e00c7c43e2d9b603a5a6af576b1685e400410dee /spec/frontend/vue_shared | |
parent | 879f5329ee916a948223f8f43d77fba4da6cd028 (diff) |
Add latest changes from gitlab-org/gitlab@13-5-stable-eev13.5.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared')
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<script>alert("XSS!!")</script>'); + }); + + 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, + }, + ], + ]); + }); + }); +}); |