diff options
Diffstat (limited to 'spec/frontend')
608 files changed, 16244 insertions, 12029 deletions
diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index ea36f1dabaf..237f8b408f5 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -18,8 +18,16 @@ jest.mock('@gitlab/ui/dist/directives/tooltip.js', () => ({ })); jest.mock('@gitlab/ui/dist/components/base/tooltip/tooltip.js', () => ({ + props: ['target', 'id', 'triggers', 'placement', 'container', 'boundary', 'disabled'], render(h) { - return h('div', this.$attrs, this.$slots.default); + return h( + 'div', + { + class: 'gl-tooltip', + ...this.$attrs, + }, + this.$slots.default, + ); }, })); diff --git a/spec/frontend/__mocks__/lodash/debounce.js b/spec/frontend/__mocks__/lodash/debounce.js index 97fdb39097a..e8b61c80147 100644 --- a/spec/frontend/__mocks__/lodash/debounce.js +++ b/spec/frontend/__mocks__/lodash/debounce.js @@ -8,4 +8,15 @@ // [2]: https://gitlab.com/gitlab-org/gitlab/-/issues/213378 // Further reference: https://github.com/facebook/jest/issues/3465 -export default fn => fn; +export default fn => { + const debouncedFn = jest.fn().mockImplementation(fn); + debouncedFn.cancel = jest.fn(); + debouncedFn.flush = jest.fn().mockImplementation(() => { + const errorMessage = + "The .flush() method returned by lodash.debounce is not yet implemented/mocked by the mock in 'spec/frontend/__mocks__/lodash/debounce.js'."; + + throw new Error(errorMessage); + }); + + return debouncedFn; +}; diff --git a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js index 6904e34db5d..1a3b151afa0 100644 --- a/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -78,7 +78,7 @@ describe('AddContextCommitsModal', () => { findSearch().vm.$emit('input', searchText); expect(searchCommits).not.toBeCalled(); jest.advanceTimersByTime(500); - expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText, undefined); + expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText); }); it('disabled ok button when no row is selected', () => { @@ -119,18 +119,17 @@ describe('AddContextCommitsModal', () => { wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; findModal().vm.$emit('ok'); return wrapper.vm.$nextTick().then(() => { - expect(createContextCommits).toHaveBeenCalledWith( - expect.anything(), - { commits: [{ ...commit, isSelected: true }], forceReload: true }, - undefined, - ); + expect(createContextCommits).toHaveBeenCalledWith(expect.anything(), { + commits: [{ ...commit, isSelected: true }], + forceReload: true, + }); }); }); it('"removeContextCommits" when only added commits are to be removed ', () => { wrapper.vm.$store.state.toRemoveCommits = [commit.short_id]; findModal().vm.$emit('ok'); return wrapper.vm.$nextTick().then(() => { - expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), true, undefined); + expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), true); }); }); it('"createContextCommits" and "removeContextCommits" when new commits are to be added and old commits are to be removed', () => { @@ -138,12 +137,10 @@ describe('AddContextCommitsModal', () => { wrapper.vm.$store.state.toRemoveCommits = [commit.short_id]; findModal().vm.$emit('ok'); return wrapper.vm.$nextTick().then(() => { - expect(createContextCommits).toHaveBeenCalledWith( - expect.anything(), - { commits: [{ ...commit, isSelected: true }] }, - undefined, - ); - expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + expect(createContextCommits).toHaveBeenCalledWith(expect.anything(), { + commits: [{ ...commit, isSelected: true }], + }); + expect(removeContextCommits).toHaveBeenCalledWith(expect.anything(), undefined); }); }); }); @@ -156,7 +153,7 @@ describe('AddContextCommitsModal', () => { }); it('"resetModalState" to reset all the modal state', () => { findModal().vm.$emit('cancel'); - expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined); }); }); @@ -168,7 +165,7 @@ describe('AddContextCommitsModal', () => { }); it('"resetModalState" to reset all the modal state', () => { findModal().vm.$emit('close'); - expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined); }); }); }); diff --git a/spec/frontend/ajax_loading_spinner_spec.js b/spec/frontend/ajax_loading_spinner_spec.js deleted file mode 100644 index 8ed2ee49ff8..00000000000 --- a/spec/frontend/ajax_loading_spinner_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import $ from 'jquery'; -import AjaxLoadingSpinner from '~/ajax_loading_spinner'; - -describe('Ajax Loading Spinner', () => { - const fixtureTemplate = 'static/ajax_loading_spinner.html'; - preloadFixtures(fixtureTemplate); - - beforeEach(() => { - loadFixtures(fixtureTemplate); - AjaxLoadingSpinner.init(); - }); - - it('change current icon with spinner icon and disable link while waiting ajax response', done => { - jest.spyOn($, 'ajax').mockImplementation(req => { - const xhr = new XMLHttpRequest(); - const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); - const icon = ajaxLoadingSpinner.querySelector('i'); - - req.beforeSend(xhr, { dataType: 'text/html' }); - - expect(icon).not.toHaveClass('fa-trash-o'); - expect(icon).toHaveClass('fa-spinner'); - expect(icon).toHaveClass('fa-spin'); - expect(icon.dataset.icon).toEqual('fa-trash-o'); - expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(''); - - req.complete({}); - - done(); - const deferred = $.Deferred(); - return deferred.promise(); - }); - document.querySelector('.js-ajax-loading-spinner').click(); - }); - - it('use original icon again and enabled the link after complete the ajax request', done => { - jest.spyOn($, 'ajax').mockImplementation(req => { - const xhr = new XMLHttpRequest(); - const ajaxLoadingSpinner = document.querySelector('.js-ajax-loading-spinner'); - - req.beforeSend(xhr, { dataType: 'text/html' }); - req.complete({}); - - const icon = ajaxLoadingSpinner.querySelector('i'); - - expect(icon).toHaveClass('fa-trash-o'); - expect(icon).not.toHaveClass('fa-spinner'); - expect(icon).not.toHaveClass('fa-spin'); - expect(ajaxLoadingSpinner.getAttribute('disabled')).toEqual(null); - - done(); - const deferred = $.Deferred(); - return deferred.promise(); - }); - document.querySelector('.js-ajax-loading-spinner').click(); - }); -}); diff --git a/spec/frontend/alert_handler_spec.js b/spec/frontend/alert_handler_spec.js new file mode 100644 index 00000000000..ba2f4f24aa5 --- /dev/null +++ b/spec/frontend/alert_handler_spec.js @@ -0,0 +1,46 @@ +import { setHTMLFixture } from 'helpers/fixtures'; +import initAlertHandler from '~/alert_handler'; + +describe('Alert Handler', () => { + const ALERT_SELECTOR = 'gl-alert'; + const CLOSE_SELECTOR = 'gl-alert-dismiss'; + const ALERT_HTML = `<div class="${ALERT_SELECTOR}"><button class="${CLOSE_SELECTOR}">Dismiss</button></div>`; + + const findFirstAlert = () => document.querySelector(`.${ALERT_SELECTOR}`); + const findAllAlerts = () => document.querySelectorAll(`.${ALERT_SELECTOR}`); + const findFirstCloseButton = () => document.querySelector(`.${CLOSE_SELECTOR}`); + + describe('initAlertHandler', () => { + describe('with one alert', () => { + beforeEach(() => { + setHTMLFixture(ALERT_HTML); + initAlertHandler(); + }); + + it('should render the alert', () => { + expect(findFirstAlert()).toExist(); + }); + + it('should dismiss the alert on click', () => { + findFirstCloseButton().click(); + expect(findFirstAlert()).not.toExist(); + }); + }); + + describe('with two alerts', () => { + beforeEach(() => { + setHTMLFixture(ALERT_HTML + ALERT_HTML); + initAlertHandler(); + }); + + it('should render two alerts', () => { + expect(findAllAlerts()).toHaveLength(2); + }); + + it('should dismiss only one alert on click', () => { + findFirstCloseButton().click(); + expect(findAllAlerts()).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_details_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js index 2c4ed100a56..8aa26dbca3b 100644 --- a/spec/frontend/alert_management/components/alert_details_spec.js +++ b/spec/frontend/alert_management/components/alert_details_spec.js @@ -1,7 +1,8 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GlAlert, GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; import AlertDetails from '~/alert_management/components/alert_details.vue'; import createIssueMutation from '~/alert_management/graphql/mutations/create_issue_from_alert.mutation.graphql'; import { joinPaths } from '~/lib/utils/url_utility'; @@ -22,8 +23,6 @@ describe('AlertDetails', () => { const projectId = '1'; const $router = { replace: jest.fn() }; - const findDetailsTable = () => wrapper.find(GlTable); - function mountComponent({ data, loading = false, mountMethod = shallowMount, stubs = {} } = {}) { wrapper = mountMethod(AlertDetails, { provide: { @@ -66,6 +65,7 @@ describe('AlertDetails', () => { const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]'); const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]'); + const findDetailsTable = () => wrapper.find(AlertDetailsTable); describe('Alert details', () => { describe('when alert is null', () => { @@ -87,8 +87,8 @@ describe('AlertDetails', () => { expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true); }); - it('renders a tab with full alert information', () => { - expect(wrapper.find('[data-testid="fullDetails"]').exists()).toBe(true); + it('renders a tab with an activity feed', () => { + expect(wrapper.find('[data-testid="activity"]').exists()).toBe(true); }); it('renders severity', () => { @@ -198,7 +198,6 @@ describe('AlertDetails', () => { mountComponent({ data: { alert: mockAlert } }); }); it('should display a table of raw alert details data', () => { - wrapper.find('[data-testid="fullDetails"]').trigger('click'); expect(findDetailsTable().exists()).toBe(true); }); }); @@ -234,7 +233,7 @@ describe('AlertDetails', () => { describe('header', () => { const findHeader = () => wrapper.find('[data-testid="alert-header"]'); - const stubs = { TimeAgoTooltip: '<span>now</span>' }; + const stubs = { TimeAgoTooltip: { template: '<span>now</span>' } }; describe('individual header fields', () => { describe.each` @@ -268,8 +267,8 @@ describe('AlertDetails', () => { it.each` index | tabId ${0} | ${'overview'} - ${1} | ${'fullDetails'} - ${2} | ${'metrics'} + ${1} | ${'metrics'} + ${2} | ${'activity'} `('will navigate to the correct tab via $tabId', ({ index, tabId }) => { wrapper.setData({ currentTabIndex: index }); expect($router.replace).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); diff --git a/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js b/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js index 2814b5ce357..ea7b4584a63 100644 --- a/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js +++ b/spec/frontend/alert_management/components/alert_management_sidebar_todo_spec.js @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import SidebarTodo from '~/alert_management/components/sidebar/sidebar_todo.vue'; -import AlertMarkTodo from '~/alert_management/graphql/mutations/alert_todo_create.mutation.graphql'; +import createAlertTodoMutation from '~/alert_management/graphql/mutations/alert_todo_create.mutation.graphql'; +import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; @@ -61,14 +62,14 @@ describe('Alert Details Sidebar To Do', () => { expect(findToDoButton().text()).toBe('Add a To-Do'); }); - it('calls `$apollo.mutate` with `AlertMarkTodo` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', async () => { + it('calls `$apollo.mutate` with `createAlertTodoMutation` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', async () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); findToDoButton().trigger('click'); await wrapper.vm.$nextTick(); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: AlertMarkTodo, + mutation: createAlertTodoMutation, variables: { iid: '1527542', projectPath: 'projectPath', @@ -76,6 +77,7 @@ describe('Alert Details Sidebar To Do', () => { }); }); }); + describe('removing a todo', () => { beforeEach(() => { mountComponent({ @@ -91,12 +93,19 @@ describe('Alert Details Sidebar To Do', () => { expect(findToDoButton().text()).toBe('Mark as done'); }); - it('calls `$apollo.mutate` with `AlertMarkTodoDone` mutation and variables containing `id`', async () => { + it('calls `$apollo.mutate` with `todoMarkDoneMutation` mutation and variables containing `id`', async () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); findToDoButton().trigger('click'); await wrapper.vm.$nextTick(); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: todoMarkDoneMutation, + update: expect.anything(), + variables: { + id: '1234', + }, + }); }); }); }); diff --git a/spec/frontend/alert_management/components/alert_management_table_spec.js b/spec/frontend/alert_management/components/alert_management_table_spec.js index 5dd0d9dc1ba..bcad415eb19 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -11,6 +11,7 @@ import { GlBadge, GlPagination, GlSearchBoxByType, + GlAvatar, } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -39,19 +40,21 @@ describe('AlertManagementTable', () => { const findStatusFilterBadge = () => wrapper.findAll(GlBadge); const findDateFields = () => wrapper.findAll(TimeAgo); const findFirstStatusOption = () => findStatusDropdown().find(GlDeprecatedDropdownItem); - const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); - const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); - const findSeverityColumnHeader = () => wrapper.findAll('th').at(0); const findPagination = () => wrapper.find(GlPagination); const findSearch = () => wrapper.find(GlSearchBoxByType); + const findSeverityColumnHeader = () => + wrapper.find('[data-testid="alert-management-severity-sort"]'); + const findFirstIDField = () => wrapper.findAll('[data-testid="idField"]').at(0); + const findAssignees = () => wrapper.findAll('[data-testid="assigneesField"]'); + const findSeverityFields = () => wrapper.findAll('[data-testid="severityField"]'); const findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); const findAlertError = () => wrapper.find('[data-testid="alert-error"]'); const alertsCount = { - open: 14, - triggered: 10, - acknowledged: 6, - resolved: 1, - all: 16, + open: 24, + triggered: 20, + acknowledged: 16, + resolved: 11, + all: 26, }; const selectFirstStatusOption = () => { findFirstStatusOption().vm.$emit('click'); @@ -92,13 +95,10 @@ describe('AlertManagementTable', () => { }); } - beforeEach(() => { - mountComponent({ data: { alerts: mockAlerts, alertsCount } }); - }); - afterEach(() => { if (wrapper) { wrapper.destroy(); + wrapper = null; } }); @@ -192,6 +192,17 @@ describe('AlertManagementTable', () => { ).toContain('gl-hover-bg-blue-50'); }); + it('displays the alert ID and title formatted correctly', () => { + mountComponent({ + props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, + loading: false, + }); + + expect(findFirstIDField().exists()).toBe(true); + expect(findFirstIDField().text()).toBe(`#${mockAlerts[0].iid} ${mockAlerts[0].title}`); + }); + it('displays status dropdown', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, @@ -207,7 +218,11 @@ describe('AlertManagementTable', () => { data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); - expect(findStatusDropdown().contains('.dropdown-title')).toBe(false); + expect( + findStatusDropdown() + .find('.dropdown-title') + .exists(), + ).toBe(false); }); it('shows correct severity icons', () => { @@ -255,18 +270,22 @@ describe('AlertManagementTable', () => { ).toBe('Unassigned'); }); - it('renders username(s) when assignee(s) present', () => { + it('renders user avatar when assignee present', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); - expect( - findAssignees() - .at(1) - .text(), - ).toBe(mockAlerts[1].assignees.nodes[0].username); + const avatar = findAssignees() + .at(1) + .find(GlAvatar); + const { src, label } = avatar.attributes(); + const { name, avatarUrl } = mockAlerts[1].assignees.nodes[0]; + + expect(avatar.exists()).toBe(true); + expect(label).toBe(name); + expect(src).toBe(avatarUrl); }); it('navigates to the detail page when alert row is clicked', () => { @@ -502,7 +521,11 @@ describe('AlertManagementTable', () => { await selectFirstStatusOption(); expect(findAlertError().exists()).toBe(true); - expect(findAlertError().contains('[data-testid="htmlError"]')).toBe(true); + expect( + findAlertError() + .find('[data-testid="htmlError"]') + .exists(), + ).toBe(true); }); }); diff --git a/spec/frontend/alert_management/components/alert_metrics_spec.js b/spec/frontend/alert_management/components/alert_metrics_spec.js index e0a069fa1a8..42da8c3768b 100644 --- a/spec/frontend/alert_management/components/alert_metrics_spec.js +++ b/spec/frontend/alert_management/components/alert_metrics_spec.js @@ -3,15 +3,13 @@ import waitForPromises from 'helpers/wait_for_promises'; import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; import AlertMetrics from '~/alert_management/components/alert_metrics.vue'; +import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; jest.mock('~/monitoring/stores', () => ({ monitoringDashboard: {}, })); -const mockEmbedName = 'MetricsEmbedStub'; - jest.mock('~/monitoring/components/embeds/metric_embed.vue', () => ({ - name: mockEmbedName, render(h) { return h('div'); }, @@ -26,13 +24,10 @@ describe('Alert Metrics', () => { propsData: { ...props, }, - stubs: { - MetricEmbed: true, - }, }); } - const findChart = () => wrapper.find({ name: mockEmbedName }); + const findChart = () => wrapper.find(MetricEmbed); const findEmptyState = () => wrapper.find({ ref: 'emptyState' }); afterEach(() => { diff --git a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js index a14596b6722..4c9db02eff4 100644 --- a/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_managment_sidebar_assignees_spec.js @@ -56,6 +56,9 @@ describe('Alert Details Sidebar Assignees', () => { mock.restore(); }); + const findAssigned = () => wrapper.find('[data-testid="assigned-users"]'); + const findUnassigned = () => wrapper.find('[data-testid="unassigned-users"]'); + describe('updating the alert status', () => { const mockUpdatedMutationResult = { data: { @@ -100,32 +103,30 @@ describe('Alert Details Sidebar Assignees', () => { }); }); - it('renders a unassigned option', () => { + it('renders a unassigned option', async () => { wrapper.setData({ isDropdownSearching: false }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned'); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned'); }); - it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', () => { + it('calls `$apollo.mutate` with `AlertSetAssignees` mutation and variables containing `iid`, `assigneeUsernames`, & `projectPath`', async () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); wrapper.setData({ isDropdownSearching: false }); - return wrapper.vm.$nextTick().then(() => { - wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root'); + await wrapper.vm.$nextTick(); + wrapper.find(SidebarAssignee).vm.$emit('update-alert-assignees', 'root'); - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: AlertSetAssignees, - variables: { - iid: '1527542', - assigneeUsernames: ['root'], - projectPath: 'projectPath', - }, - }); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: AlertSetAssignees, + variables: { + iid: '1527542', + assigneeUsernames: ['root'], + projectPath: 'projectPath', + }, }); }); - it('shows an error when request contains error messages', () => { + it('emits an error when request contains error messages', () => { wrapper.setData({ isDropdownSearching: false }); const errorMutationResult = { data: { @@ -137,18 +138,48 @@ describe('Alert Details Sidebar Assignees', () => { }; jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(errorMutationResult); - - return wrapper.vm.$nextTick().then(() => { - const SideBarAssigneeItem = wrapper.findAll(SidebarAssignee).at(0); - SideBarAssigneeItem.vm.$emit('click'); - expect(wrapper.emitted('alert-refresh')).toBeUndefined(); - }); + return wrapper.vm + .$nextTick() + .then(() => { + const SideBarAssigneeItem = wrapper.findAll(SidebarAssignee).at(0); + SideBarAssigneeItem.vm.$emit('update-alert-assignees'); + }) + .then(() => { + expect(wrapper.emitted('alert-error')).toBeDefined(); + }); }); it('stops updating and cancels loading when the request fails', () => { jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); wrapper.vm.updateAlertAssignees('root'); - expect(wrapper.find('[data-testid="unassigned-users"]').text()).toBe('assign yourself'); + expect(findUnassigned().text()).toBe('assign yourself'); + }); + + it('shows a user avatar, username and full name when a user is set', () => { + mountComponent({ + data: { alert: mockAlerts[1] }, + sidebarCollapsed: false, + loading: false, + stubs: { + SidebarAssignee, + }, + }); + + expect( + findAssigned() + .find('img') + .attributes('src'), + ).toBe('/url'); + expect( + findAssigned() + .find('.dropdown-menu-user-full-name') + .text(), + ).toBe('root'); + expect( + findAssigned() + .find('.dropdown-menu-user-username') + .text(), + ).toBe('root'); }); }); }); diff --git a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js index 5bd0d3b3c17..a8fe40687e1 100644 --- a/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/alert_management/components/sidebar/alert_sidebar_status_spec.js @@ -56,7 +56,11 @@ describe('Alert Details Sidebar Status', () => { }); it('displays the dropdown status header', () => { - expect(findStatusDropdown().contains('.dropdown-title')).toBe(true); + expect( + findStatusDropdown() + .find('.dropdown-title') + .exists(), + ).toBe(true); }); describe('updating the alert status', () => { diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json index fec101a52b4..5267a4fe50d 100644 --- a/spec/frontend/alert_management/mocks/alerts.json +++ b/spec/frontend/alert_management/mocks/alerts.json @@ -20,7 +20,7 @@ "startedAt": "2020-04-17T23:18:14.996Z", "endedAt": "2020-04-17T23:18:14.996Z", "status": "ACKNOWLEDGED", - "assignees": { "nodes": [{ "username": "root" }] }, + "assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] }, "issueIid": "1", "notes": { "nodes": [ @@ -49,7 +49,7 @@ "startedAt": "2020-04-17T23:18:14.996Z", "endedAt": "2020-04-17T23:18:14.996Z", "status": "RESOLVED", - "assignees": { "nodes": [{ "username": "root" }] }, + "assignees": { "nodes": [{ "username": "root", "avatarUrl": "/url", "name": "root" }] }, "notes": { "nodes": [ { diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 4f4de62c229..3ae0d06162d 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -398,6 +398,29 @@ describe('Api', () => { }); }); + describe('projectMilestones', () => { + it('fetches project milestones', done => { + const projectId = 1; + const options = { state: 'active' }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/1/milestones`; + mock.onGet(expectedUrl).reply(200, [ + { + id: 1, + title: 'milestone1', + state: 'active', + }, + ]); + + Api.projectMilestones(projectId, options) + .then(({ data }) => { + expect(data.length).toBe(1); + expect(data[0].title).toBe('milestone1'); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('newLabel', () => { it('creates a new label', done => { const namespace = 'some namespace'; diff --git a/spec/frontend/authentication/u2f/authenticate_spec.js b/spec/frontend/authentication/u2f/authenticate_spec.js index 8abef2ae1b2..7a87b420195 100644 --- a/spec/frontend/authentication/u2f/authenticate_spec.js +++ b/spec/frontend/authentication/u2f/authenticate_spec.js @@ -76,7 +76,7 @@ describe('U2FAuthenticate', () => { describe('errors', () => { it('displays an error message', () => { - const setupButton = container.find('#js-login-u2f-device'); + const setupButton = container.find('#js-login-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToAuthenticateRequest({ errorCode: 'error!', @@ -87,14 +87,14 @@ describe('U2FAuthenticate', () => { }); it('allows retrying authentication after an error', () => { - let setupButton = container.find('#js-login-u2f-device'); + let setupButton = container.find('#js-login-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToAuthenticateRequest({ errorCode: 'error!', }); const retryButton = container.find('#js-token-2fa-try-again'); retryButton.trigger('click'); - setupButton = container.find('#js-login-u2f-device'); + setupButton = container.find('#js-login-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToAuthenticateRequest({ deviceData: 'this is data from the device', diff --git a/spec/frontend/authentication/u2f/register_spec.js b/spec/frontend/authentication/u2f/register_spec.js index 3c2ecdbba66..e89ef773be6 100644 --- a/spec/frontend/authentication/u2f/register_spec.js +++ b/spec/frontend/authentication/u2f/register_spec.js @@ -13,8 +13,8 @@ describe('U2FRegister', () => { beforeEach(done => { loadFixtures('u2f/register.html'); u2fDevice = new MockU2FDevice(); - container = $('#js-register-u2f'); - component = new U2FRegister(container, $('#js-register-u2f-templates'), {}, 'token'); + container = $('#js-register-token-2fa'); + component = new U2FRegister(container, {}); component .start() .then(done) @@ -22,9 +22,9 @@ describe('U2FRegister', () => { }); it('allows registering a U2F device', () => { - const setupButton = container.find('#js-setup-u2f-device'); + const setupButton = container.find('#js-setup-token-2fa-device'); - expect(setupButton.text()).toBe('Set up new U2F device'); + expect(setupButton.text()).toBe('Set up new device'); setupButton.trigger('click'); const inProgressMessage = container.children('p'); @@ -41,7 +41,7 @@ describe('U2FRegister', () => { describe('errors', () => { it("doesn't allow the same device to be registered twice (for the same user", () => { - const setupButton = container.find('#js-setup-u2f-device'); + const setupButton = container.find('#js-setup-token-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToRegisterRequest({ errorCode: 4, @@ -52,7 +52,7 @@ describe('U2FRegister', () => { }); it('displays an error message for other errors', () => { - const setupButton = container.find('#js-setup-u2f-device'); + const setupButton = container.find('#js-setup-token-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToRegisterRequest({ errorCode: 'error!', @@ -63,14 +63,14 @@ describe('U2FRegister', () => { }); it('allows retrying registration after an error', () => { - let setupButton = container.find('#js-setup-u2f-device'); + let setupButton = container.find('#js-setup-token-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToRegisterRequest({ errorCode: 'error!', }); - const retryButton = container.find('#U2FTryAgain'); + const retryButton = container.find('#js-token-2fa-try-again'); retryButton.trigger('click'); - setupButton = container.find('#js-setup-u2f-device'); + setupButton = container.find('#js-setup-token-2fa-device'); setupButton.trigger('click'); u2fDevice.respondToRegisterRequest({ deviceData: 'this is data from the device', diff --git a/spec/frontend/authentication/webauthn/authenticate_spec.js b/spec/frontend/authentication/webauthn/authenticate_spec.js new file mode 100644 index 00000000000..0a82adfd0ee --- /dev/null +++ b/spec/frontend/authentication/webauthn/authenticate_spec.js @@ -0,0 +1,132 @@ +import $ from 'jquery'; +import waitForPromises from 'helpers/wait_for_promises'; +import WebAuthnAuthenticate from '~/authentication/webauthn/authenticate'; +import MockWebAuthnDevice from './mock_webauthn_device'; +import { useMockNavigatorCredentials } from './util'; + +const mockResponse = { + type: 'public-key', + id: '', + rawId: '', + response: { clientDataJSON: '', authenticatorData: '', signature: '', userHandle: '' }, + getClientExtensionResults: () => {}, +}; + +describe('WebAuthnAuthenticate', () => { + preloadFixtures('webauthn/authenticate.html'); + useMockNavigatorCredentials(); + + let fallbackElement; + let webAuthnDevice; + let container; + let component; + let submitSpy; + + const findDeviceResponseInput = () => container[0].querySelector('#js-device-response'); + const findDeviceResponseInputValue = () => findDeviceResponseInput().value; + const findMessage = () => container[0].querySelector('p'); + const findRetryButton = () => container[0].querySelector('#js-token-2fa-try-again'); + const expectAuthenticated = () => { + expect(container.text()).toMatchInterpolatedText( + 'We heard back from your device. You have been authenticated.', + ); + expect(findDeviceResponseInputValue()).toBe(JSON.stringify(mockResponse)); + expect(submitSpy).toHaveBeenCalled(); + }; + + beforeEach(() => { + loadFixtures('webauthn/authenticate.html'); + fallbackElement = document.createElement('div'); + fallbackElement.classList.add('js-2fa-form'); + webAuthnDevice = new MockWebAuthnDevice(); + container = $('#js-authenticate-token-2fa'); + component = new WebAuthnAuthenticate( + container, + '#js-login-token-2fa-form', + { + options: + // we need some valid base64 for base64ToBuffer + // so we use "YQ==" = base64("a") + JSON.stringify({ + challenge: 'YQ==', + timeout: 120000, + allowCredentials: [ + { type: 'public-key', id: 'YQ==' }, + { type: 'public-key', id: 'YQ==' }, + ], + userVerification: 'discouraged', + }), + }, + document.querySelector('#js-login-2fa-device'), + fallbackElement, + ); + submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit'); + }); + + describe('with webauthn unavailable', () => { + let oldGetCredentials; + + beforeEach(() => { + oldGetCredentials = window.navigator.credentials.get; + window.navigator.credentials.get = null; + }); + + afterEach(() => { + window.navigator.credentials.get = oldGetCredentials; + }); + + it('falls back to normal 2fa', () => { + component.start(); + + expect(container.html()).toBe(''); + expect(container[0]).toHaveClass('hidden'); + expect(fallbackElement).not.toHaveClass('hidden'); + }); + }); + + describe('with webauthn available', () => { + beforeEach(() => { + component.start(); + }); + + it('shows in progress', () => { + const inProgressMessage = container.find('p'); + + expect(inProgressMessage.text()).toMatchInterpolatedText( + "Trying to communicate with your device. Plug it in (if you haven't already) and press the button on the device now.", + ); + }); + + it('allows authenticating via a WebAuthn device', () => { + webAuthnDevice.respondToAuthenticateRequest(mockResponse); + + return waitForPromises().then(() => { + expectAuthenticated(); + }); + }); + + describe('errors', () => { + beforeEach(() => { + webAuthnDevice.rejectAuthenticateRequest(new DOMException()); + + return waitForPromises(); + }); + + it('displays an error message', () => { + expect(submitSpy).not.toHaveBeenCalled(); + expect(findMessage().textContent).toMatchInterpolatedText( + 'There was a problem communicating with your device. (Error)', + ); + }); + + it('allows retrying authentication after an error', () => { + findRetryButton().click(); + webAuthnDevice.respondToAuthenticateRequest(mockResponse); + + return waitForPromises().then(() => { + expectAuthenticated(); + }); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/error_spec.js b/spec/frontend/authentication/webauthn/error_spec.js new file mode 100644 index 00000000000..26f1ca5e27d --- /dev/null +++ b/spec/frontend/authentication/webauthn/error_spec.js @@ -0,0 +1,50 @@ +import WebAuthnError from '~/authentication/webauthn/error'; + +describe('WebAuthnError', () => { + it.each([ + [ + 'NotSupportedError', + 'Your device is not compatible with GitLab. Please try another device', + 'authenticate', + ], + ['InvalidStateError', 'This device has not been registered with us.', 'authenticate'], + ['InvalidStateError', 'This device has already been registered with us.', 'register'], + ['UnknownError', 'There was a problem communicating with your device.', 'register'], + ])('exception %s will have message %s, flow type: %s', (exception, expectedMessage, flowType) => { + expect(new WebAuthnError(new DOMException('', exception), flowType).message()).toEqual( + expectedMessage, + ); + }); + + describe('SecurityError', () => { + const { location } = window; + + beforeEach(() => { + delete window.location; + window.location = {}; + }); + + afterEach(() => { + window.location = location; + }); + + it('returns a descriptive error if https is disabled', () => { + window.location.protocol = 'http:'; + + const expectedMessage = + 'WebAuthn only works with HTTPS-enabled websites. Contact your administrator for more details.'; + expect( + new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(), + ).toEqual(expectedMessage); + }); + + it('returns a generic error if https is enabled', () => { + window.location.protocol = 'https:'; + + const expectedMessage = 'There was a problem communicating with your device.'; + expect( + new WebAuthnError(new DOMException('', 'SecurityError'), 'authenticate').message(), + ).toEqual(expectedMessage); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/mock_webauthn_device.js b/spec/frontend/authentication/webauthn/mock_webauthn_device.js new file mode 100644 index 00000000000..39df94df46b --- /dev/null +++ b/spec/frontend/authentication/webauthn/mock_webauthn_device.js @@ -0,0 +1,35 @@ +/* eslint-disable no-unused-expressions */ + +export default class MockWebAuthnDevice { + constructor() { + this.respondToAuthenticateRequest = this.respondToAuthenticateRequest.bind(this); + this.respondToRegisterRequest = this.respondToRegisterRequest.bind(this); + window.navigator.credentials || (window.navigator.credentials = {}); + window.navigator.credentials.create = () => + new Promise((resolve, reject) => { + this.registerCallback = resolve; + this.registerRejectCallback = reject; + }); + window.navigator.credentials.get = () => + new Promise((resolve, reject) => { + this.authenticateCallback = resolve; + this.authenticateRejectCallback = reject; + }); + } + + respondToRegisterRequest(params) { + return this.registerCallback(params); + } + + respondToAuthenticateRequest(params) { + return this.authenticateCallback(params); + } + + rejectRegisterRequest(params) { + return this.registerRejectCallback(params); + } + + rejectAuthenticateRequest(params) { + return this.authenticateRejectCallback(params); + } +} diff --git a/spec/frontend/authentication/webauthn/register_spec.js b/spec/frontend/authentication/webauthn/register_spec.js new file mode 100644 index 00000000000..1de952d176d --- /dev/null +++ b/spec/frontend/authentication/webauthn/register_spec.js @@ -0,0 +1,131 @@ +import $ from 'jquery'; +import waitForPromises from 'helpers/wait_for_promises'; +import WebAuthnRegister from '~/authentication/webauthn/register'; +import MockWebAuthnDevice from './mock_webauthn_device'; +import { useMockNavigatorCredentials } from './util'; + +describe('WebAuthnRegister', () => { + preloadFixtures('webauthn/register.html'); + useMockNavigatorCredentials(); + + const mockResponse = { + type: 'public-key', + id: '', + rawId: '', + response: { + clientDataJSON: '', + attestationObject: '', + }, + getClientExtensionResults: () => {}, + }; + let webAuthnDevice; + let container; + let component; + + beforeEach(() => { + loadFixtures('webauthn/register.html'); + webAuthnDevice = new MockWebAuthnDevice(); + container = $('#js-register-token-2fa'); + component = new WebAuthnRegister(container, { + options: { + rp: '', + user: { + id: '', + name: '', + displayName: '', + }, + challenge: '', + pubKeyCredParams: '', + }, + }); + component.start(); + }); + + const findSetupButton = () => container.find('#js-setup-token-2fa-device'); + const findMessage = () => container.find('p'); + const findDeviceResponse = () => container.find('#js-device-response'); + const findRetryButton = () => container.find('#js-token-2fa-try-again'); + + it('shows setup button', () => { + expect(findSetupButton().text()).toBe('Set up new device'); + }); + + describe('when unsupported', () => { + const { location, PublicKeyCredential } = window; + + beforeEach(() => { + delete window.location; + delete window.credentials; + window.location = {}; + window.PublicKeyCredential = undefined; + }); + + afterEach(() => { + window.location = location; + window.PublicKeyCredential = PublicKeyCredential; + }); + + it.each` + httpsEnabled | expectedText + ${false} | ${'WebAuthn only works with HTTPS-enabled websites'} + ${true} | ${'Please use a supported browser, e.g. Chrome (67+) or Firefox'} + `('when https is $httpsEnabled', ({ httpsEnabled, expectedText }) => { + window.location.protocol = httpsEnabled ? 'https:' : 'http:'; + component.start(); + + expect(findMessage().text()).toContain(expectedText); + }); + }); + + describe('when setup', () => { + beforeEach(() => { + findSetupButton().trigger('click'); + }); + + it('shows in progress message', () => { + expect(findMessage().text()).toContain('Trying to communicate with your device'); + }); + + it('registers device', () => { + webAuthnDevice.respondToRegisterRequest(mockResponse); + + return waitForPromises().then(() => { + expect(findMessage().text()).toContain('Your device was successfully set up!'); + expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse)); + }); + }); + + it.each` + errorName | expectedText + ${'NotSupportedError'} | ${'Your device is not compatible with GitLab'} + ${'NotAllowedError'} | ${'There was a problem communicating with your device'} + `('when fails with $errorName', ({ errorName, expectedText }) => { + webAuthnDevice.rejectRegisterRequest(new DOMException('', errorName)); + + return waitForPromises().then(() => { + expect(findMessage().text()).toContain(expectedText); + expect(findRetryButton().length).toBe(1); + }); + }); + + it('can retry', () => { + webAuthnDevice.respondToRegisterRequest({ + errorCode: 'error!', + }); + + return waitForPromises() + .then(() => { + findRetryButton().click(); + + expect(findMessage().text()).toContain('Trying to communicate with your device'); + + webAuthnDevice.respondToRegisterRequest(mockResponse); + return waitForPromises(); + }) + .then(() => { + expect(findMessage().text()).toContain('Your device was successfully set up!'); + expect(findDeviceResponse().val()).toBe(JSON.stringify(mockResponse)); + }); + }); + }); +}); diff --git a/spec/frontend/authentication/webauthn/util.js b/spec/frontend/authentication/webauthn/util.js new file mode 100644 index 00000000000..d8f5a67ee1f --- /dev/null +++ b/spec/frontend/authentication/webauthn/util.js @@ -0,0 +1,19 @@ +export function useMockNavigatorCredentials() { + let oldNavigatorCredentials; + let oldPublicKeyCredential; + + beforeEach(() => { + oldNavigatorCredentials = navigator.credentials; + oldPublicKeyCredential = window.PublicKeyCredential; + navigator.credentials = { + get: jest.fn(), + create: jest.fn(), + }; + window.PublicKeyCredential = function MockPublicKeyCredential() {}; + }); + + afterEach(() => { + navigator.credentials = oldNavigatorCredentials; + window.PublicKeyCredential = oldPublicKeyCredential; + }); +} diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 1a1738ecf4a..f0ed18248f0 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -4,7 +4,6 @@ import MockAdapter from 'axios-mock-adapter'; import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; import axios from '~/lib/utils/axios_utils'; import loadAwardsHandler from '~/awards_handler'; -import { setTestTimeout } from './helpers/timeout'; import { EMOJI_VERSION } from '~/emoji'; window.gl = window.gl || {}; @@ -17,7 +16,44 @@ const urlRoot = gon.relative_url_root; describe('AwardsHandler', () => { useFakeRequestAnimationFrame(); - const emojiData = getJSONFixture('emojis/emojis.json'); + const emojiData = { + '8ball': { + c: 'activity', + e: '🎱', + d: 'billiards', + u: '6.0', + }, + grinning: { + c: 'people', + e: '😀', + d: 'grinning face', + u: '6.1', + }, + angel: { + c: 'people', + e: '👼', + d: 'baby angel', + u: '6.0', + }, + anger: { + c: 'symbols', + e: '💢', + d: 'anger symbol', + u: '6.0', + }, + alien: { + c: 'people', + e: '👽', + d: 'extraterrestrial alien', + u: '6.0', + }, + sunglasses: { + c: 'people', + e: '😎', + d: 'smiling face with sunglasses', + u: '6.0', + }, + }; preloadFixtures('snippets/show.html'); const openAndWaitForEmojiMenu = (sel = '.js-add-award') => { @@ -25,7 +61,7 @@ describe('AwardsHandler', () => { .eq(0) .click(); - jest.advanceTimersByTime(200); + jest.runOnlyPendingTimers(); const $menu = $('.emoji-menu'); @@ -37,10 +73,6 @@ describe('AwardsHandler', () => { }; beforeEach(async () => { - // These tests have had some timeout issues - // https://gitlab.com/gitlab-org/gitlab/-/issues/221086 - setTestTimeout(6000); - mock = new MockAdapter(axios); mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); diff --git a/spec/frontend/badges/components/badge_settings_spec.js b/spec/frontend/badges/components/badge_settings_spec.js index 8c3f1ea2749..b6a86746598 100644 --- a/spec/frontend/badges/components/badge_settings_spec.js +++ b/spec/frontend/badges/components/badge_settings_spec.js @@ -82,14 +82,14 @@ describe('BadgeSettings component', () => { const form = vm.$el.querySelector('form:nth-of-type(1)'); expect(form).not.toBe(null); - const submitButton = form.querySelector('.btn-success'); - - expect(submitButton).not.toBe(null); - expect(submitButton).toHaveText(/Save changes/); - const cancelButton = form.querySelector('.btn-cancel'); + const cancelButton = form.querySelector('[data-testid="cancelEditing"]'); expect(cancelButton).not.toBe(null); expect(cancelButton).toHaveText(/Cancel/); + const submitButton = form.querySelector('[data-testid="saveEditing"]'); + + expect(submitButton).not.toBe(null); + expect(submitButton).toHaveText(/Save changes/); }); it('displays no badge list', () => { diff --git a/spec/frontend/batch_comments/mock_data.js b/spec/frontend/batch_comments/mock_data.js index 5601e489066..973f0d469d4 100644 --- a/spec/frontend/batch_comments/mock_data.js +++ b/spec/frontend/batch_comments/mock_data.js @@ -1,6 +1,5 @@ import { TEST_HOST } from 'spec/test_constants'; -// eslint-disable-next-line import/prefer-default-export export const createDraft = () => ({ author: { id: 1, diff --git a/spec/frontend/behaviors/autosize_spec.js b/spec/frontend/behaviors/autosize_spec.js index 59abae479d4..3444c7b4075 100644 --- a/spec/frontend/behaviors/autosize_spec.js +++ b/spec/frontend/behaviors/autosize_spec.js @@ -1,20 +1,24 @@ -import $ from 'jquery'; import '~/behaviors/autosize'; function load() { - $(document).trigger('load'); + document.dispatchEvent(new Event('DOMContentLoaded')); } +jest.mock('~/helpers/startup_css_helper', () => { + return { + waitForCSSLoaded: jest.fn().mockImplementation(cb => cb.apply()), + }; +}); + describe('Autosize behavior', () => { beforeEach(() => { - setFixtures('<textarea class="js-autosize" style="resize: vertical"></textarea>'); + setFixtures('<textarea class="js-autosize"></textarea>'); }); - it('does not overwrite the resize property', () => { + it('is applied to the textarea', () => { load(); - expect($('textarea')).toHaveCss({ - resize: 'vertical', - }); + const textarea = document.querySelector('textarea'); + expect(textarea.classList).toContain('js-autosize-initialized'); }); }); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index ef6b1673b7c..46b4e5d3d5c 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -10,7 +10,20 @@ jest.mock('~/emoji/support'); describe('gl_emoji', () => { let mock; - const emojiData = getJSONFixture('emojis/emojis.json'); + const emojiData = { + grey_question: { + c: 'symbols', + e: '❔', + d: 'white question mark ornament', + u: '6.0', + }, + bomb: { + c: 'objects', + e: '💣', + d: 'bomb', + u: '6.0', + }, + }; beforeAll(() => { jest.spyOn(EmojiUnicodeSupport, 'default').mockReturnValue(true); diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js index 9232a709194..3db95e5ad3f 100644 --- a/spec/frontend/blob/components/blob_content_spec.js +++ b/spec/frontend/blob/components/blob_content_spec.js @@ -36,20 +36,20 @@ describe('Blob Content component', () => { describe('rendering', () => { it('renders loader if `loading: true`', () => { createComponent({ loading: true }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); - expect(wrapper.contains(BlobContentError)).toBe(false); - expect(wrapper.contains(RichViewer)).toBe(false); - expect(wrapper.contains(SimpleViewer)).toBe(false); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.find(BlobContentError).exists()).toBe(false); + expect(wrapper.find(RichViewer).exists()).toBe(false); + expect(wrapper.find(SimpleViewer).exists()).toBe(false); }); it('renders error if there is any in the viewer', () => { const renderError = 'Oops'; const viewer = { ...SimpleViewerMock, renderError }; createComponent({}, viewer); - expect(wrapper.contains(GlLoadingIcon)).toBe(false); - expect(wrapper.contains(BlobContentError)).toBe(true); - expect(wrapper.contains(RichViewer)).toBe(false); - expect(wrapper.contains(SimpleViewer)).toBe(false); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find(BlobContentError).exists()).toBe(true); + expect(wrapper.find(RichViewer).exists()).toBe(false); + expect(wrapper.find(SimpleViewer).exists()).toBe(false); }); it.each` @@ -60,7 +60,7 @@ describe('Blob Content component', () => { 'renders $type viewer when activeViewer is $type and no loading or error detected', ({ mock, viewer }) => { createComponent({}, mock); - expect(wrapper.contains(viewer)).toBe(true); + expect(wrapper.find(viewer).exists()).toBe(true); }, ); diff --git a/spec/frontend/blob/components/blob_edit_content_spec.js b/spec/frontend/blob/components/blob_edit_content_spec.js index 3cc210e972c..dbed086a552 100644 --- a/spec/frontend/blob/components/blob_edit_content_spec.js +++ b/spec/frontend/blob/components/blob_edit_content_spec.js @@ -2,12 +2,14 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import BlobEditContent from '~/blob/components/blob_edit_content.vue'; import * as utils from '~/blob/utils'; -import Editor from '~/editor/editor_lite'; jest.mock('~/editor/editor_lite'); describe('Blob Header Editing', () => { let wrapper; + const onDidChangeModelContent = jest.fn(); + const updateModelLanguage = jest.fn(); + const getValue = jest.fn(); const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; const fileName = 'lorem.txt'; const fileGlobalId = 'snippet_777'; @@ -24,7 +26,12 @@ describe('Blob Header Editing', () => { } beforeEach(() => { - jest.spyOn(utils, 'initEditorLite'); + jest.spyOn(utils, 'initEditorLite').mockImplementation(() => ({ + onDidChangeModelContent, + updateModelLanguage, + getValue, + dispose: jest.fn(), + })); createComponent(); }); @@ -34,8 +41,8 @@ describe('Blob Header Editing', () => { }); const triggerChangeContent = val => { - jest.spyOn(Editor.prototype, 'getValue').mockReturnValue(val); - const [cb] = Editor.prototype.onChangeContent.mock.calls[0]; + getValue.mockReturnValue(val); + const [cb] = onDidChangeModelContent.mock.calls[0]; cb(); @@ -58,7 +65,7 @@ describe('Blob Header Editing', () => { createComponent({ value: undefined }); expect(spy).not.toHaveBeenCalled(); - expect(wrapper.contains('#editor')).toBe(true); + expect(wrapper.find('#editor').exists()).toBe(true); }); it('initialises Editor Lite', () => { @@ -79,12 +86,12 @@ describe('Blob Header Editing', () => { }); return nextTick().then(() => { - expect(Editor.prototype.updateModelLanguage).toHaveBeenCalledWith(newFileName); + expect(updateModelLanguage).toHaveBeenCalledWith(newFileName); }); }); it('registers callback with editor onChangeContent', () => { - expect(Editor.prototype.onChangeContent).toHaveBeenCalledWith(expect.any(Function)); + expect(onDidChangeModelContent).toHaveBeenCalledWith(expect.any(Function)); }); it('emits input event when the blob content is changed', () => { diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js index c71595a79cf..4355f46db7e 100644 --- a/spec/frontend/blob/components/blob_edit_header_spec.js +++ b/spec/frontend/blob/components/blob_edit_header_spec.js @@ -31,7 +31,7 @@ describe('Blob Header Editing', () => { }); it('contains a form input field', () => { - expect(wrapper.contains(GlFormInput)).toBe(true); + expect(wrapper.find(GlFormInput).exists()).toBe(true); }); it('does not show delete button', () => { diff --git a/spec/frontend/blob/components/blob_embeddable_spec.js b/spec/frontend/blob/components/blob_embeddable_spec.js deleted file mode 100644 index 1f6790013ca..00000000000 --- a/spec/frontend/blob/components/blob_embeddable_spec.js +++ /dev/null @@ -1,35 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlFormInputGroup } from '@gitlab/ui'; -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; - -describe('Blob Embeddable', () => { - let wrapper; - const url = 'https://foo.bar'; - - function createComponent() { - wrapper = shallowMount(BlobEmbeddable, { - propsData: { - url, - }, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders gl-form-input-group component', () => { - expect(wrapper.find(GlFormInputGroup).exists()).toBe(true); - }); - - it('makes up optionValues based on the url prop', () => { - expect(wrapper.vm.optionValues).toEqual([ - { name: 'Embed', value: expect.stringContaining(`${url}.js`) }, - { name: 'Share', value: url }, - ]); - }); -}); diff --git a/spec/frontend/blob/pipeline_tour_success_mock_data.js b/spec/frontend/blob/pipeline_tour_success_mock_data.js index 7819fcce85d..9dea3969d63 100644 --- a/spec/frontend/blob/pipeline_tour_success_mock_data.js +++ b/spec/frontend/blob/pipeline_tour_success_mock_data.js @@ -1,5 +1,6 @@ const modalProps = { goToPipelinesPath: 'some_pipeline_path', + projectMergeRequestsPath: 'some_mr_path', commitCookie: 'some_cookie', humanAccess: 'maintainer', }; diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index 9998cd7f91c..50db1675e13 100644 --- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -10,10 +10,7 @@ describe('PipelineTourSuccessModal', () => { let cookieSpy; let trackingSpy; - beforeEach(() => { - document.body.dataset.page = 'projects:blob:show'; - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - + const createComponent = () => { wrapper = shallowMount(pipelineTourSuccess, { propsData: modalProps, stubs: { @@ -21,13 +18,49 @@ describe('PipelineTourSuccessModal', () => { GlSprintf, }, }); + }; + beforeEach(() => { + document.body.dataset.page = 'projects:blob:show'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); cookieSpy = jest.spyOn(Cookies, 'remove'); + createComponent(); }); afterEach(() => { wrapper.destroy(); unmockTracking(); + Cookies.remove(modalProps.commitCookie); + }); + + describe('when the commitCookie contains the mr path', () => { + const expectedMrPath = 'expected_mr_path'; + + beforeEach(() => { + Cookies.set(modalProps.commitCookie, expectedMrPath); + createComponent(); + }); + + it('renders the path from the commit cookie for back to the merge request button', () => { + const goToMrBtn = wrapper.find({ ref: 'goToMergeRequest' }); + + expect(goToMrBtn.attributes('href')).toBe(expectedMrPath); + }); + }); + + describe('when the commitCookie does not contain mr path', () => { + const expectedMrPath = modalProps.projectMergeRequestsPath; + + beforeEach(() => { + Cookies.set(modalProps.commitCookie, true); + createComponent(); + }); + + it('renders the path from projectMergeRequestsPath for back to the merge request button', () => { + const goToMrBtn = wrapper.find({ ref: 'goToMergeRequest' }); + + expect(goToMrBtn.attributes('href')).toBe(expectedMrPath); + }); }); it('has expected structure', () => { @@ -58,7 +91,7 @@ describe('PipelineTourSuccessModal', () => { it('send an event when go to pipelines is clicked', () => { trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - const goToBtn = wrapper.find({ ref: 'goto' }); + const goToBtn = wrapper.find({ ref: 'goToPipelines' }); triggerEvent(goToBtn.element); expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { @@ -67,5 +100,17 @@ describe('PipelineTourSuccessModal', () => { value: '10', }); }); + + it('sends an event when back to the merge request is clicked', () => { + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + const goToBtn = wrapper.find({ ref: 'goToMergeRequest' }); + triggerEvent(goToBtn.element); + + expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { + label: 'congratulate_first_pipeline', + property: modalProps.humanAccess, + value: '20', + }); + }); }); }); diff --git a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js index 4714d34dbec..e55b8e4af24 100644 --- a/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js +++ b/spec/frontend/blob/suggest_gitlab_ci_yml/components/popover_spec.js @@ -16,6 +16,7 @@ const commitTrackLabel = 'suggest_commit_first_project_gitlab_ci_yml'; const dismissCookie = 'suggest_gitlab_ci_yml_99'; const humanAccess = 'owner'; +const mergeRequestPath = '/some/path'; describe('Suggest gitlab-ci.yml Popover', () => { let wrapper; @@ -26,10 +27,11 @@ describe('Suggest gitlab-ci.yml Popover', () => { target, trackLabel, dismissKey, + mergeRequestPath, humanAccess, }, stubs: { - 'gl-popover': '<div><slot name="title"></slot><slot></slot></div>', + 'gl-popover': { template: '<div><slot name="title"></slot><slot></slot></div>' }, }, }); } diff --git a/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js new file mode 100644 index 00000000000..8dc71f99010 --- /dev/null +++ b/spec/frontend/blob/suggest_web_ide_ci/web_ide_alert_spec.js @@ -0,0 +1,67 @@ +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlAlert } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import WebIdeAlert from '~/blob/suggest_web_ide_ci/components/web_ide_alert.vue'; + +const dismissEndpoint = '/-/user_callouts'; +const featureId = 'web_ide_alert_dismissed'; +const editPath = 'edit/master/-/.gitlab-ci.yml'; + +describe('WebIdeAlert', () => { + let wrapper; + let mock; + + const findButton = () => wrapper.find(GlButton); + const findAlert = () => wrapper.find(GlAlert); + const dismissAlert = alertWrapper => alertWrapper.vm.$emit('dismiss'); + const getPostPayload = () => JSON.parse(mock.history.post[0].data); + + const createComponent = () => { + wrapper = shallowMount(WebIdeAlert, { + propsData: { + dismissEndpoint, + featureId, + editPath, + }, + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + + mock.onPost(dismissEndpoint).reply(200); + + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + mock.restore(); + }); + + describe('with defaults', () => { + it('displays alert correctly', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('web ide button link has correct path', () => { + expect(findButton().attributes('href')).toBe(editPath); + }); + + it('dismisses alert correctly', async () => { + const alertWrapper = findAlert(); + + dismissAlert(alertWrapper); + + await waitForPromises(); + + expect(alertWrapper.exists()).toBe(false); + expect(mock.history.post).toHaveLength(1); + expect(getPostPayload()).toEqual({ feature_name: featureId }); + }); + }); +}); diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js index 98fa96de124..a105b62586b 100644 --- a/spec/frontend/blob_edit/blob_bundle_spec.js +++ b/spec/frontend/blob_edit/blob_bundle_spec.js @@ -43,7 +43,8 @@ describe('BlobBundle', () => { data-target="#target" data-track-label="suggest_gitlab_ci_yml" data-dismiss-key="1" - data-human-access="owner"> + data-human-access="owner" + data-merge-request-path="path/to/mr"> <button id='commit-changes' class="js-commit-button"></button> <a class="btn btn-cancel" href="#"></a> </div> diff --git a/spec/frontend/blob_edit/edit_blob_spec.js b/spec/frontend/blob_edit/edit_blob_spec.js index 9642b55b9b4..8f92e8498b9 100644 --- a/spec/frontend/blob_edit/edit_blob_spec.js +++ b/spec/frontend/blob_edit/edit_blob_spec.js @@ -1,15 +1,18 @@ import EditBlob from '~/blob_edit/edit_blob'; import EditorLite from '~/editor/editor_lite'; import MarkdownExtension from '~/editor/editor_markdown_ext'; +import FileTemplateExtension from '~/editor/editor_file_template_ext'; jest.mock('~/editor/editor_lite'); jest.mock('~/editor/editor_markdown_ext'); describe('Blob Editing', () => { + const mockInstance = 'foo'; beforeEach(() => { setFixtures( - `<div class="js-edit-blob-form"><div id="file_path"></div><div id="iditor"></div><input id="file-content"></div>`, + `<div class="js-edit-blob-form"><div id="file_path"></div><div id="editor"></div><input id="file-content"></div>`, ); + jest.spyOn(EditorLite.prototype, 'createInstance').mockReturnValue(mockInstance); }); const initEditor = (isMarkdown = false) => { @@ -19,13 +22,29 @@ describe('Blob Editing', () => { }); }; - it('does not load MarkdownExtension by default', async () => { + it('loads FileTemplateExtension by default', async () => { await initEditor(); - expect(EditorLite.prototype.use).not.toHaveBeenCalled(); + expect(EditorLite.prototype.use).toHaveBeenCalledWith( + expect.arrayContaining([FileTemplateExtension]), + mockInstance, + ); }); - it('loads MarkdownExtension only for the markdown files', async () => { - await initEditor(true); - expect(EditorLite.prototype.use).toHaveBeenCalledWith(MarkdownExtension); + describe('Markdown', () => { + it('does not load MarkdownExtension by default', async () => { + await initEditor(); + expect(EditorLite.prototype.use).not.toHaveBeenCalledWith( + expect.arrayContaining([MarkdownExtension]), + mockInstance, + ); + }); + + it('loads MarkdownExtension only for the markdown files', async () => { + await initEditor(true); + expect(EditorLite.prototype.use).toHaveBeenCalledWith( + [MarkdownExtension, FileTemplateExtension], + mockInstance, + ); + }); }); }); diff --git a/spec/frontend/boards/board_list_helper.js b/spec/frontend/boards/board_list_helper.js index b51a82f2a35..80d7a72151d 100644 --- a/spec/frontend/boards/board_list_helper.js +++ b/spec/frontend/boards/board_list_helper.js @@ -52,10 +52,12 @@ export default function createComponent({ list, issues: list.issues, loading: false, - issueLinkBase: '/issues', - rootPath: '/', ...componentProps, }, + provide: { + groupId: null, + rootPath: '/', + }, }).$mount(); Vue.nextTick(() => { diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 3a64b004847..88883ae61d4 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -45,10 +45,12 @@ const createComponent = ({ done, listIssueProps = {}, componentProps = {}, listP list, issues: list.issues, loading: false, - issueLinkBase: '/issues', - rootPath: '/', ...componentProps, }, + provide: { + groupId: null, + rootPath: '/', + }, }).$mount(); Vue.nextTick(() => { diff --git a/spec/frontend/boards/board_new_issue_spec.js b/spec/frontend/boards/board_new_issue_spec.js index 94afc8a2b45..3eebfeca965 100644 --- a/spec/frontend/boards/board_new_issue_spec.js +++ b/spec/frontend/boards/board_new_issue_spec.js @@ -1,6 +1,7 @@ /* global List */ import Vue from 'vue'; +import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import boardNewIssue from '~/boards/components/board_new_issue.vue'; @@ -10,6 +11,7 @@ import '~/boards/models/list'; import { listObj, boardsMockInterceptor } from './mock_data'; describe('Issue boards new issue form', () => { + let wrapper; let vm; let list; let mock; @@ -24,13 +26,11 @@ describe('Issue boards new issue form', () => { const dummySubmitEvent = { preventDefault() {}, }; - vm.$refs.submitButton = vm.$el.querySelector('.btn-success'); - return vm.submit(dummySubmitEvent); + wrapper.vm.$refs.submitButton = wrapper.find({ ref: 'submitButton' }); + return wrapper.vm.submit(dummySubmitEvent); }; beforeEach(() => { - setFixtures('<div class="test-container"></div>'); - const BoardNewIssueComp = Vue.extend(boardNewIssue); mock = new MockAdapter(axios); @@ -43,46 +43,52 @@ describe('Issue boards new issue form', () => { newIssueMock = Promise.resolve(promiseReturn); jest.spyOn(list, 'newIssue').mockImplementation(() => newIssueMock); - vm = new BoardNewIssueComp({ + wrapper = mount(BoardNewIssueComp, { propsData: { + disabled: false, list, }, - }).$mount(document.querySelector('.test-container')); + provide: { + groupId: null, + }, + }); + + vm = wrapper.vm; return Vue.nextTick(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); mock.restore(); }); it('calls submit if submit button is clicked', () => { - jest.spyOn(vm, 'submit').mockImplementation(e => e.preventDefault()); + jest.spyOn(wrapper.vm, 'submit').mockImplementation(); vm.title = 'Testing Title'; - return Vue.nextTick().then(() => { - vm.$el.querySelector('.btn-success').click(); - - expect(vm.submit.mock.calls.length).toBe(1); - }); + return Vue.nextTick() + .then(submitIssue) + .then(() => { + expect(wrapper.vm.submit).toHaveBeenCalled(); + }); }); it('disables submit button if title is empty', () => { - expect(vm.$el.querySelector('.btn-success').disabled).toBe(true); + expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(true); }); it('enables submit button if title is not empty', () => { - vm.title = 'Testing Title'; + wrapper.setData({ title: 'Testing Title' }); return Vue.nextTick().then(() => { - expect(vm.$el.querySelector('.form-control').value).toBe('Testing Title'); - expect(vm.$el.querySelector('.btn-success').disabled).not.toBe(true); + expect(wrapper.find({ ref: 'input' }).element.value).toBe('Testing Title'); + expect(wrapper.find({ ref: 'submitButton' }).props().disabled).toBe(false); }); }); it('clears title after clicking cancel', () => { - vm.$el.querySelector('.btn-default').click(); + wrapper.find({ ref: 'cancelButton' }).trigger('click'); return Vue.nextTick().then(() => { expect(vm.title).toBe(''); @@ -97,7 +103,7 @@ describe('Issue boards new issue form', () => { describe('submit success', () => { it('creates new issue', () => { - vm.title = 'submit title'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -107,17 +113,18 @@ describe('Issue boards new issue form', () => { }); it('enables button after submit', () => { - vm.title = 'submit issue'; + jest.spyOn(wrapper.vm, 'submit').mockImplementation(); + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) .then(() => { - expect(vm.$el.querySelector('.btn-success').disabled).toBe(false); + expect(wrapper.vm.$refs.submitButton.props().disabled).toBe(false); }); }); it('clears title after submit', () => { - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -128,7 +135,7 @@ describe('Issue boards new issue form', () => { it('sets detail issue after submit', () => { expect(boardsStore.detail.issue.title).toBe(undefined); - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -138,7 +145,7 @@ describe('Issue boards new issue form', () => { }); it('sets detail list after submit', () => { - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -149,7 +156,7 @@ describe('Issue boards new issue form', () => { it('sets detail weight after submit', () => { boardsStore.weightFeatureAvailable = true; - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) @@ -160,7 +167,7 @@ describe('Issue boards new issue form', () => { it('does not set detail weight after submit', () => { boardsStore.weightFeatureAvailable = false; - vm.title = 'submit issue'; + wrapper.setData({ title: 'submit issue' }); return Vue.nextTick() .then(submitIssue) diff --git a/spec/frontend/boards/boards_store_spec.js b/spec/frontend/boards/boards_store_spec.js index 29cc8f981bd..41971137b95 100644 --- a/spec/frontend/boards/boards_store_spec.js +++ b/spec/frontend/boards/boards_store_spec.js @@ -312,7 +312,7 @@ describe('boardsStore', () => { }); describe('newIssue', () => { - const id = 'not-creative'; + const id = 1; const issue = { some: 'issue data' }; const url = `${endpoints.listsEndpoint}/${id}/issues`; const expectedRequest = expect.objectContaining({ diff --git a/spec/frontend/boards/components/board_card_layout_spec.js b/spec/frontend/boards/components/board_card_layout_spec.js new file mode 100644 index 00000000000..80f649a1a96 --- /dev/null +++ b/spec/frontend/boards/components/board_card_layout_spec.js @@ -0,0 +1,95 @@ +/* global List */ +/* global ListLabel */ + +import { shallowMount } from '@vue/test-utils'; + +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; + +import '~/boards/models/label'; +import '~/boards/models/assignee'; +import '~/boards/models/list'; +import store from '~/boards/stores'; +import boardsStore from '~/boards/stores/boards_store'; +import BoardCardLayout from '~/boards/components/board_card_layout.vue'; +import issueCardInner from '~/boards/components/issue_card_inner.vue'; +import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; + +describe('Board card layout', () => { + let wrapper; + let mock; + let list; + + // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized + const mountComponent = propsData => { + wrapper = shallowMount(BoardCardLayout, { + stubs: { + issueCardInner, + }, + store, + propsData: { + list, + issue: list.issues[0], + disabled: false, + index: 0, + ...propsData, + }, + provide: { + groupId: null, + rootPath: '/', + }, + }); + }; + + const setupData = () => { + list = new List(listObj); + boardsStore.create(); + boardsStore.detail.issue = {}; + const label1 = new ListLabel({ + id: 3, + title: 'testing 123', + color: '#000cff', + text_color: 'white', + description: 'test', + }); + return waitForPromises().then(() => { + list.issues[0].labels.push(label1); + }); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onAny().reply(boardsMockInterceptor); + setMockEndpoints(); + return setupData(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + list = null; + mock.restore(); + }); + + describe('mouse events', () => { + it('sets showDetail to true on mousedown', async () => { + mountComponent(); + + wrapper.trigger('mousedown'); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.showDetail).toBe(true); + }); + + it('sets showDetail to false on mousemove', async () => { + mountComponent(); + wrapper.trigger('mousedown'); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.showDetail).toBe(true); + wrapper.trigger('mousemove'); + await wrapper.vm.$nextTick(); + expect(wrapper.vm.showDetail).toBe(false); + }); + }); +}); diff --git a/spec/frontend/boards/board_card_spec.js b/spec/frontend/boards/components/board_card_spec.js index d01b895f996..a3ddcdf01b7 100644 --- a/spec/frontend/boards/board_card_spec.js +++ b/spec/frontend/boards/components/board_card_spec.js @@ -2,7 +2,7 @@ /* global ListAssignee */ /* global ListLabel */ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; @@ -15,12 +15,12 @@ import '~/boards/models/assignee'; import '~/boards/models/list'; import store from '~/boards/stores'; import boardsStore from '~/boards/stores/boards_store'; -import boardCard from '~/boards/components/board_card.vue'; +import BoardCard from '~/boards/components/board_card.vue'; import issueCardInner from '~/boards/components/issue_card_inner.vue'; import userAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import { listObj, boardsMockInterceptor, setMockEndpoints } from './mock_data'; +import { listObj, boardsMockInterceptor, setMockEndpoints } from '../mock_data'; -describe('Board card', () => { +describe('BoardCard', () => { let wrapper; let mock; let list; @@ -30,7 +30,7 @@ describe('Board card', () => { // this particular mount component needs to be used after the root beforeEach because it depends on list being initialized const mountComponent = propsData => { - wrapper = shallowMount(boardCard, { + wrapper = mount(BoardCard, { stubs: { issueCardInner, }, @@ -38,16 +38,18 @@ describe('Board card', () => { propsData: { list, issue: list.issues[0], - issueLinkBase: '/', disabled: false, index: 0, - rootPath: '/', ...propsData, }, + provide: { + groupId: null, + rootPath: '/', + }, }); }; - const setupData = () => { + const setupData = async () => { list = new List(listObj); boardsStore.create(); boardsStore.detail.issue = {}; @@ -58,9 +60,9 @@ describe('Board card', () => { text_color: 'white', description: 'test', }); - return waitForPromises().then(() => { - list.issues[0].labels.push(label1); - }); + await waitForPromises(); + + list.issues[0].labels.push(label1); }; beforeEach(() => { @@ -79,7 +81,7 @@ describe('Board card', () => { it('when details issue is empty does not show the element', () => { mountComponent(); - expect(wrapper.classes()).not.toContain('is-active'); + expect(wrapper.find('[data-testid="board_card"').classes()).not.toContain('is-active'); }); it('when detailIssue is equal to card issue shows the element', () => { @@ -124,29 +126,6 @@ describe('Board card', () => { }); describe('mouse events', () => { - it('sets showDetail to true on mousedown', () => { - mountComponent(); - wrapper.trigger('mousedown'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.showDetail).toBe(true); - }); - }); - - it('sets showDetail to false on mousemove', () => { - mountComponent(); - wrapper.trigger('mousedown'); - return wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.vm.showDetail).toBe(true); - wrapper.trigger('mousemove'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.vm.showDetail).toBe(false); - }); - }); - it('does not set detail issue if showDetail is false', () => { mountComponent(); expect(boardsStore.detail.issue).toEqual({}); @@ -219,6 +198,9 @@ describe('Board card', () => { boardsStore.detail.issue = {}; mountComponent(); + // sets conditional so that event is emitted. + wrapper.trigger('mousedown'); + wrapper.trigger('mouseup'); expect(sidebarEventHub.$emit).toHaveBeenCalledWith('sidebar.closeAll'); diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index c06b7aceaad..2a4dbbb989e 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -59,10 +59,11 @@ describe('Board Column Component', () => { propsData: { boardId, disabled: false, - issueLinkBase: '/', - rootPath: '/', list, }, + provide: { + boardId, + }, }); }; diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js new file mode 100644 index 00000000000..df117d06cdf --- /dev/null +++ b/spec/frontend/boards/components/board_content_spec.js @@ -0,0 +1,64 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlAlert } from '@gitlab/ui'; +import EpicsSwimlanes from 'ee_component/boards/components/epics_swimlanes.vue'; +import BoardColumn from 'ee_else_ce/boards/components/board_column.vue'; +import getters from 'ee_else_ce/boards/stores/getters'; +import { mockListsWithModel } from '../mock_data'; +import BoardContent from '~/boards/components/board_content.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('BoardContent', () => { + let wrapper; + + const defaultState = { + isShowingEpicsSwimlanes: false, + boardLists: mockListsWithModel, + error: undefined, + }; + + const createStore = (state = defaultState) => { + return new Vuex.Store({ + getters, + state, + actions: { + fetchIssuesForAllLists: () => {}, + }, + }); + }; + + const createComponent = state => { + const store = createStore({ + ...defaultState, + ...state, + }); + wrapper = shallowMount(BoardContent, { + localVue, + propsData: { + lists: mockListsWithModel, + canAdminList: true, + disabled: false, + }, + store, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders a BoardColumn component per list', () => { + expect(wrapper.findAll(BoardColumn)).toHaveLength(mockListsWithModel.length); + }); + + it('does not display EpicsSwimlanes component', () => { + expect(wrapper.find(EpicsSwimlanes).exists()).toBe(false); + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); +}); diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index b1d277863e8..65d8070192c 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -11,7 +11,7 @@ describe('board_form.vue', () => { const propsData = { canAdminBoard: false, labelsPath: `${TEST_HOST}/labels/path`, - milestonePath: `${TEST_HOST}/milestone/path`, + labelsWebUrl: `${TEST_HOST}/-/labels`, }; const findModal = () => wrapper.find(DeprecatedModal); diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 76a3d5e71c8..2439c347bf0 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -57,12 +57,12 @@ describe('Board List Header Component', () => { wrapper = shallowMount(BoardListHeader, { propsData: { - boardId, disabled: false, - issueLinkBase: '/', - rootPath: '/', list, }, + provide: { + boardId, + }, }); }; @@ -106,7 +106,7 @@ describe('Board List Header Component', () => { createComponent(); expect(isCollapsed()).toBe(false); - wrapper.find('[data-testid="board-list-header"]').vm.$emit('click'); + wrapper.find('[data-testid="board-list-header"]').trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(isCollapsed()).toBe(false); diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js index f39adc0fc49..12c9431f2d4 100644 --- a/spec/frontend/boards/components/board_settings_sidebar_spec.js +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -6,8 +6,9 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlDrawer, GlLabel } from '@gitlab/ui'; import BoardSettingsSidebar from '~/boards/components/board_settings_sidebar.vue'; import boardsStore from '~/boards/stores/boards_store'; +import { createStore } from '~/boards/stores'; import sidebarEventHub from '~/sidebar/event_hub'; -import { inactiveId } from '~/boards/constants'; +import { inactiveId, LIST } from '~/boards/constants'; const localVue = createLocalVue(); @@ -16,19 +17,12 @@ localVue.use(Vuex); describe('BoardSettingsSidebar', () => { let wrapper; let mock; - let storeActions; + let store; const labelTitle = 'test'; const labelColor = '#FFFF'; const listId = 1; - const createComponent = (state = { activeId: inactiveId }, actions = {}) => { - storeActions = actions; - - const store = new Vuex.Store({ - state, - actions: storeActions, - }); - + const createComponent = () => { wrapper = shallowMount(BoardSettingsSidebar, { store, localVue, @@ -38,6 +32,9 @@ describe('BoardSettingsSidebar', () => { const findDrawer = () => wrapper.find(GlDrawer); beforeEach(() => { + store = createStore(); + store.state.activeId = inactiveId; + store.state.sidebarType = LIST; boardsStore.create(); }); @@ -46,114 +43,125 @@ describe('BoardSettingsSidebar', () => { wrapper.destroy(); }); - it('finds a GlDrawer component', () => { - createComponent(); + describe('when sidebarType is "list"', () => { + it('finds a GlDrawer component', () => { + createComponent(); - expect(findDrawer().exists()).toBe(true); - }); + expect(findDrawer().exists()).toBe(true); + }); - describe('on close', () => { - it('calls closeSidebar', async () => { - const spy = jest.fn(); - createComponent({ activeId: inactiveId }, { setActiveId: spy }); + describe('on close', () => { + it('closes the sidebar', async () => { + createComponent(); - findDrawer().vm.$emit('close'); + findDrawer().vm.$emit('close'); - await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); - expect(storeActions.setActiveId).toHaveBeenCalledWith( - expect.anything(), - inactiveId, - undefined, - ); - }); + expect(wrapper.find(GlDrawer).exists()).toBe(false); + }); - it('calls closeSidebar on sidebar.closeAll event', async () => { - createComponent({ activeId: inactiveId }, { setActiveId: jest.fn() }); + it('closes the sidebar when emitting the correct event', async () => { + createComponent(); - sidebarEventHub.$emit('sidebar.closeAll'); + sidebarEventHub.$emit('sidebar.closeAll'); - await wrapper.vm.$nextTick(); + await wrapper.vm.$nextTick(); - expect(storeActions.setActiveId).toHaveBeenCalledWith( - expect.anything(), - inactiveId, - undefined, - ); + expect(wrapper.find(GlDrawer).exists()).toBe(false); + }); }); - }); - describe('when activeId is zero', () => { - it('renders GlDrawer with open false', () => { - createComponent(); + describe('when activeId is zero', () => { + it('renders GlDrawer with open false', () => { + createComponent(); - expect(findDrawer().props('open')).toBe(false); + expect(findDrawer().props('open')).toBe(false); + }); }); - }); - describe('when activeId is greater than zero', () => { - beforeEach(() => { - mock = new MockAdapter(axios); + describe('when activeId is greater than zero', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + boardsStore.addList({ + id: listId, + label: { title: labelTitle, color: labelColor }, + list_type: 'label', + }); + store.state.activeId = 1; + store.state.sidebarType = LIST; + }); - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', + afterEach(() => { + boardsStore.removeList(listId); }); - }); - afterEach(() => { - boardsStore.removeList(listId); + it('renders GlDrawer with open false', () => { + createComponent(); + + expect(findDrawer().props('open')).toBe(true); + }); }); - it('renders GlDrawer with open false', () => { - createComponent({ activeId: 1 }); + describe('when activeId is in boardsStore', () => { + beforeEach(() => { + mock = new MockAdapter(axios); - expect(findDrawer().props('open')).toBe(true); - }); - }); + boardsStore.addList({ + id: listId, + label: { title: labelTitle, color: labelColor }, + list_type: 'label', + }); - describe('when activeId is in boardsStore', () => { - beforeEach(() => { - mock = new MockAdapter(axios); + store.state.activeId = listId; + store.state.sidebarType = LIST; - boardsStore.addList({ - id: listId, - label: { title: labelTitle, color: labelColor }, - list_type: 'label', + createComponent(); }); - createComponent({ activeId: listId }); - }); + afterEach(() => { + mock.restore(); + }); - afterEach(() => { - mock.restore(); - }); + it('renders label title', () => { + expect(findLabel().props('title')).toBe(labelTitle); + }); - it('renders label title', () => { - expect(findLabel().props('title')).toBe(labelTitle); + it('renders label background color', () => { + expect(findLabel().props('backgroundColor')).toBe(labelColor); + }); }); - it('renders label background color', () => { - expect(findLabel().props('backgroundColor')).toBe(labelColor); - }); - }); + describe('when activeId is not in boardsStore', () => { + beforeEach(() => { + mock = new MockAdapter(axios); - describe('when activeId is not in boardsStore', () => { - beforeEach(() => { - mock = new MockAdapter(axios); + boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } }); + + store.state.activeId = inactiveId; - boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } }); + createComponent(); + }); + + afterEach(() => { + mock.restore(); + }); - createComponent({ activeId: inactiveId }); + it('does not render GlLabel', () => { + expect(findLabel().exists()).toBe(false); + }); }); + }); - afterEach(() => { - mock.restore(); + describe('when sidebarType is not List', () => { + beforeEach(() => { + store.state.sidebarType = ''; + createComponent(); }); - it('does not render GlLabel', () => { - expect(findLabel().exists()).toBe(false); + it('does not render GlDrawer', () => { + expect(findDrawer().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index f2d4de238d1..2b7605a3f7c 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -81,12 +81,12 @@ describe('BoardsSelector', () => { assignee_id: null, labels: [], }, - milestonePath: `${TEST_HOST}/milestone/path`, boardBaseUrl: `${TEST_HOST}/board/base/url`, hasMissingBoards: false, canAdminBoard: true, multipleIssueBoardsAvailable: true, labelsPath: `${TEST_HOST}/labels/path`, + labelsWebUrl: `${TEST_HOST}/labels`, projectId: 42, groupId: 19, scopedIssueBoardFeatureEnabled: true, diff --git a/spec/frontend/boards/components/issuable_title_spec.js b/spec/frontend/boards/components/issuable_title_spec.js new file mode 100644 index 00000000000..4b7f491b998 --- /dev/null +++ b/spec/frontend/boards/components/issuable_title_spec.js @@ -0,0 +1,33 @@ +import { shallowMount } from '@vue/test-utils'; +import IssuableTitle from '~/boards/components/issuable_title.vue'; + +describe('IssuableTitle', () => { + let wrapper; + const defaultProps = { + title: 'One', + refPath: 'path', + }; + const createComponent = () => { + wrapper = shallowMount(IssuableTitle, { + propsData: { ...defaultProps }, + }); + }; + const findIssueContent = () => wrapper.find('[data-testid="issue-title"]'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders a title of an issue in the sidebar', () => { + expect(findIssueContent().text()).toContain('One'); + }); + + it('renders a referencePath of an issue in the sidebar', () => { + expect(findIssueContent().text()).toContain('path'); + }); +}); diff --git a/spec/frontend/boards/components/issue_count_spec.js b/spec/frontend/boards/components/issue_count_spec.js index 819d878f4e2..d1ff0bdbf88 100644 --- a/spec/frontend/boards/components/issue_count_spec.js +++ b/spec/frontend/boards/components/issue_count_spec.js @@ -29,7 +29,7 @@ describe('IssueCount', () => { }); it('does not contains maxIssueCount in the template', () => { - expect(vm.contains('.js-max-issue-size')).toBe(false); + expect(vm.find('.js-max-issue-size').exists()).toBe(false); }); }); diff --git a/spec/frontend/boards/components/sidebar/board_editable_item_spec.js b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js new file mode 100644 index 00000000000..1dbcbd06407 --- /dev/null +++ b/spec/frontend/boards/components/sidebar/board_editable_item_spec.js @@ -0,0 +1,107 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import BoardSidebarItem from '~/boards/components/sidebar/board_editable_item.vue'; + +describe('boards sidebar remove issue', () => { + let wrapper; + + const findLoader = () => wrapper.find(GlLoadingIcon); + const findEditButton = () => wrapper.find('[data-testid="edit-button"]'); + const findTitle = () => wrapper.find('[data-testid="title"]'); + const findCollapsed = () => wrapper.find('[data-testid="collapsed-content"]'); + const findExpanded = () => wrapper.find('[data-testid="expanded-content"]'); + + const createComponent = ({ props = {}, slots = {}, canUpdate = false } = {}) => { + wrapper = shallowMount(BoardSidebarItem, { + attachTo: document.body, + provide: { canUpdate }, + propsData: props, + slots, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('template', () => { + it('renders title', () => { + const title = 'Sidebar item title'; + createComponent({ props: { title } }); + + expect(findTitle().text()).toBe(title); + }); + + it('hides edit button, loader and expanded content by default', () => { + createComponent(); + + expect(findEditButton().exists()).toBe(false); + expect(findLoader().exists()).toBe(false); + expect(findExpanded().isVisible()).toBe(false); + }); + + it('shows "None" if empty collapsed slot', () => { + createComponent({}); + + expect(findCollapsed().text()).toBe('None'); + }); + + it('renders collapsed content by default', () => { + const slots = { collapsed: '<div>Collapsed content</div>' }; + createComponent({ slots }); + + expect(findCollapsed().text()).toBe('Collapsed content'); + }); + + it('shows edit button if can update', () => { + createComponent({ canUpdate: true }); + + expect(findEditButton().exists()).toBe(true); + }); + + it('shows loading icon if loading', () => { + createComponent({ props: { loading: true } }); + + expect(findLoader().exists()).toBe(true); + }); + + it('shows expanded content and hides collapsed content when clicking edit button', async () => { + const slots = { default: '<div>Select item</div>' }; + createComponent({ canUpdate: true, slots }); + findEditButton().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findCollapsed().isVisible()).toBe(false); + expect(findExpanded().isVisible()).toBe(true); + expect(findExpanded().text()).toBe('Select item'); + }); + }); + }); + + describe('collapsing an item by offclicking', () => { + beforeEach(async () => { + createComponent({ canUpdate: true }); + findEditButton().vm.$emit('click'); + await wrapper.vm.$nextTick(); + }); + + it('hides expanded section and displays collapsed section', async () => { + expect(findExpanded().isVisible()).toBe(true); + document.body.click(); + + await wrapper.vm.$nextTick(); + + expect(findCollapsed().isVisible()).toBe(true); + expect(findExpanded().isVisible()).toBe(false); + }); + + it('emits changed event', async () => { + document.body.click(); + + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().changed[1][0]).toBe(false); + }); + }); +}); diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js index dee8cb7b6e5..7e22e9647f0 100644 --- a/spec/frontend/boards/issue_card_spec.js +++ b/spec/frontend/boards/issue_card_spec.js @@ -47,13 +47,15 @@ describe('Issue card component', () => { propsData: { list, issue, - issueLinkBase: '/test', - rootPath: '/', }, store, stubs: { GlLabel: true, }, + provide: { + groupId: null, + rootPath: '/', + }, }); }); diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js index b731bb6e474..9c3a6e66ef4 100644 --- a/spec/frontend/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -184,6 +184,7 @@ describe('List model', () => { }), ); list.issues = []; + global.gon.features = { boardsWithSwimlanes: false }; }); it('adds new issue to top of list', done => { diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 8ef6efe23c7..5776332c499 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -1,3 +1,9 @@ +/* global ListIssue */ +/* global List */ + +import Vue from 'vue'; +import '~/boards/models/list'; +import '~/boards/models/issue'; import boardsStore from '~/boards/stores/boards_store'; export const boardObj = { @@ -92,11 +98,64 @@ export const mockMilestone = { due_date: '2019-12-31', }; +const assignees = [ + { + id: 'gid://gitlab/User/2', + username: 'angelina.herman', + name: 'Bernardina Bosco', + avatar: 'https://www.gravatar.com/avatar/eb7b664b13a30ad9f9ba4b61d7075470?s=80&d=identicon', + webUrl: 'http://127.0.0.1:3000/angelina.herman', + }, +]; + +const labels = [ + { + id: 'gid://gitlab/GroupLabel/5', + title: 'Cosync', + color: '#34ebec', + description: null, + }, +]; + +export const rawIssue = { + title: 'Issue 1', + id: 'gid://gitlab/Issue/436', + iid: 27, + dueDate: null, + timeEstimate: 0, + weight: null, + confidential: false, + referencePath: 'gitlab-org/test-subgroup/gitlab-test#27', + path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27', + labels: { + nodes: [ + { + id: 1, + title: 'test', + color: 'red', + description: 'testing', + }, + ], + }, + assignees: { + nodes: assignees, + }, + epic: { + id: 'gid://gitlab/Epic/41', + }, +}; + export const mockIssue = { - title: 'Testing', - id: 1, - iid: 1, + id: 'gid://gitlab/Issue/436', + iid: 27, + title: 'Issue 1', + dueDate: null, + timeEstimate: 0, + weight: null, confidential: false, + referencePath: 'gitlab-org/test-subgroup/gitlab-test#27', + path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/27', + assignees, labels: [ { id: 1, @@ -105,16 +164,64 @@ export const mockIssue = { description: 'testing', }, ], - assignees: [ - { - id: 1, - name: 'name', - username: 'username', - avatar_url: 'http://avatar_url', - }, - ], + epic: { + id: 'gid://gitlab/Epic/41', + }, }; +export const mockIssueWithModel = new ListIssue(mockIssue); + +export const mockIssue2 = { + id: 'gid://gitlab/Issue/437', + iid: 28, + title: 'Issue 2', + dueDate: null, + timeEstimate: 0, + weight: null, + confidential: false, + referencePath: 'gitlab-org/test-subgroup/gitlab-test#28', + path: '/gitlab-org/test-subgroup/gitlab-test/-/issues/28', + assignees, + labels, + epic: { + id: 'gid://gitlab/Epic/40', + }, +}; + +export const mockIssue2WithModel = new ListIssue(mockIssue2); + +export const mockIssue3 = { + id: 'gid://gitlab/Issue/438', + iid: 29, + title: 'Issue 3', + referencePath: '#29', + dueDate: null, + timeEstimate: 0, + weight: null, + confidential: false, + path: '/gitlab-org/gitlab-test/-/issues/28', + assignees, + labels, + epic: null, +}; + +export const mockIssue4 = { + id: 'gid://gitlab/Issue/439', + iid: 30, + title: 'Issue 4', + referencePath: '#30', + dueDate: null, + timeEstimate: 0, + weight: null, + confidential: false, + path: '/gitlab-org/gitlab-test/-/issues/28', + assignees, + labels, + epic: null, +}; + +export const mockIssues = [mockIssue, mockIssue2]; + export const BoardsMockData = { GET: { '/test/-/boards/1/lists/300/issues?id=300&page=1': { @@ -165,3 +272,50 @@ export const setMockEndpoints = (opts = {}) => { boardId, }); }; + +export const mockLists = [ + { + id: 'gid://gitlab/List/1', + title: 'Backlog', + position: null, + listType: 'backlog', + collapsed: false, + label: null, + assignee: null, + milestone: null, + loading: false, + }, + { + id: 'gid://gitlab/List/2', + title: 'To Do', + position: 0, + listType: 'label', + collapsed: false, + label: { + id: 'gid://gitlab/GroupLabel/121', + title: 'To Do', + color: '#F0AD4E', + textColor: '#FFFFFF', + description: null, + }, + assignee: null, + milestone: null, + loading: false, + }, +]; + +export const mockListsWithModel = mockLists.map(listMock => + Vue.observable(new List({ ...listMock, doNotFetchIssues: true })), +); + +export const mockIssuesByListId = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue3.id, mockIssue4.id], + 'gid://gitlab/List/2': mockIssues.map(({ id }) => id), +}; + +export const issues = { + [mockIssue.id]: mockIssue, + [mockIssue2.id]: mockIssue2, + [mockIssue3.id]: mockIssue3, + [mockIssue4.id]: mockIssue4, +}; diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index d539cba76ca..bdbcd435708 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,7 +1,17 @@ import testAction from 'helpers/vuex_action_helper'; -import actions from '~/boards/stores/actions'; +import { + mockListsWithModel, + mockLists, + mockIssue, + mockIssueWithModel, + mockIssue2WithModel, + rawIssue, +} from '../mock_data'; +import actions, { gqlClient } from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; -import { inactiveId } from '~/boards/constants'; +import { inactiveId, ListType } from '~/boards/constants'; +import issueMoveListMutation from '~/boards/queries/issue_move_list.mutation.graphql'; +import { fullBoardId } from '~/boards/boards_util'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -9,6 +19,10 @@ const expectNotImplemented = action => { }); }; +// We need this helper to make sure projectPath is including +// subgroups when the movIssue action is called. +const getProjectPath = path => path.split('#')[0]; + describe('setInitialBoardData', () => { it('sets data object', () => { const mockData = { @@ -26,6 +40,25 @@ describe('setInitialBoardData', () => { }); }); +describe('setFilters', () => { + it('should commit mutation SET_FILTERS', done => { + const state = { + filters: {}, + }; + + const filters = { labelName: 'label' }; + + testAction( + actions.setFilters, + filters, + state, + [{ type: types.SET_FILTERS, payload: filters }], + [], + done, + ); + }); +}); + describe('setActiveId', () => { it('should commit mutation SET_ACTIVE_ID', done => { const state = { @@ -34,17 +67,40 @@ describe('setActiveId', () => { testAction( actions.setActiveId, - 1, + { id: 1, sidebarType: 'something' }, state, - [{ type: types.SET_ACTIVE_ID, payload: 1 }], + [{ type: types.SET_ACTIVE_ID, payload: { id: 1, sidebarType: 'something' } }], [], done, ); }); }); -describe('fetchLists', () => { - expectNotImplemented(actions.fetchLists); +describe('showWelcomeList', () => { + it('should dispatch addList action', done => { + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: [{ type: 'backlog' }, { type: 'closed' }], + }; + + const blankList = { + id: 'blank', + listType: ListType.blank, + title: 'Welcome to your issue board!', + position: 0, + }; + + testAction( + actions.showWelcomeList, + {}, + state, + [], + [{ type: 'addList', payload: blankList }], + done, + ); + }); }); describe('generateDefaultLists', () => { @@ -52,29 +108,316 @@ describe('generateDefaultLists', () => { }); describe('createList', () => { - expectNotImplemented(actions.createList); + it('should dispatch addList action when creating backlog list', done => { + const backlogList = { + id: 'gid://gitlab/List/1', + listType: 'backlog', + title: 'Open', + position: 0, + }; + + jest.spyOn(gqlClient, 'mutate').mockReturnValue( + Promise.resolve({ + data: { + boardListCreate: { + list: backlogList, + errors: [], + }, + }, + }), + ); + + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: [{ type: 'closed' }], + }; + + testAction( + actions.createList, + { backlog: true }, + state, + [], + [{ type: 'addList', payload: backlogList }], + done, + ); + }); + + it('should commit CREATE_LIST_FAILURE mutation when API returns an error', done => { + jest.spyOn(gqlClient, 'mutate').mockReturnValue( + Promise.resolve({ + data: { + boardListCreate: { + list: {}, + errors: [{ foo: 'bar' }], + }, + }, + }), + ); + + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: [{ type: 'closed' }], + }; + + testAction( + actions.createList, + { backlog: true }, + state, + [{ type: types.CREATE_LIST_FAILURE }], + [], + done, + ); + }); +}); + +describe('moveList', () => { + it('should commit MOVE_LIST mutation and dispatch updateList action', done => { + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: mockListsWithModel, + }; + + testAction( + actions.moveList, + { listId: 'gid://gitlab/List/1', newIndex: 1, adjustmentValue: 1 }, + state, + [ + { + type: types.MOVE_LIST, + payload: { movedList: mockListsWithModel[0], listAtNewIndex: mockListsWithModel[1] }, + }, + ], + [ + { + type: 'updateList', + payload: { listId: 'gid://gitlab/List/1', position: 0, backupList: mockListsWithModel }, + }, + ], + done, + ); + }); }); describe('updateList', () => { - expectNotImplemented(actions.updateList); + it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + updateBoardList: { + list: {}, + errors: [{ foo: 'bar' }], + }, + }, + }); + + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: [{ type: 'closed' }], + }; + + testAction( + actions.updateList, + { listId: 'gid://gitlab/List/1', position: 1 }, + state, + [{ type: types.UPDATE_LIST_FAILURE }], + [], + done, + ); + }); }); describe('deleteList', () => { expectNotImplemented(actions.deleteList); }); -describe('fetchIssuesForList', () => { - expectNotImplemented(actions.fetchIssuesForList); -}); - describe('moveIssue', () => { - expectNotImplemented(actions.moveIssue); + const listIssues = { + 'gid://gitlab/List/1': [436, 437], + 'gid://gitlab/List/2': [], + }; + + const issues = { + '436': mockIssueWithModel, + '437': mockIssue2WithModel, + }; + + const state = { + endpoints: { fullPath: 'gitlab-org', boardId: '1' }, + boardType: 'group', + disabled: false, + boardLists: mockListsWithModel, + issuesByListId: listIssues, + issues, + }; + + it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_SUCCESS mutation when successful', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueMoveList: { + issue: rawIssue, + errors: [], + }, + }, + }); + + testAction( + actions.moveIssue, + { + issueId: '436', + issueIid: mockIssue.iid, + issuePath: mockIssue.referencePath, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + state, + [ + { + type: types.MOVE_ISSUE, + payload: { + originalIssue: mockIssueWithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + }, + { + type: types.MOVE_ISSUE_SUCCESS, + payload: { issue: rawIssue }, + }, + ], + [], + done, + ); + }); + + it('calls mutate with the correct variables', () => { + const mutationVariables = { + mutation: issueMoveListMutation, + variables: { + projectPath: getProjectPath(mockIssue.referencePath), + boardId: fullBoardId(state.endpoints.boardId), + iid: mockIssue.iid, + fromListId: 1, + toListId: 2, + moveBeforeId: undefined, + moveAfterId: undefined, + }, + }; + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueMoveList: { + issue: rawIssue, + errors: [], + }, + }, + }); + + actions.moveIssue( + { state, commit: () => {} }, + { + issueId: mockIssue.id, + issueIid: mockIssue.iid, + issuePath: mockIssue.referencePath, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + ); + + expect(gqlClient.mutate).toHaveBeenCalledWith(mutationVariables); + }); + + it('should commit MOVE_ISSUE mutation and MOVE_ISSUE_FAILURE mutation when unsuccessful', done => { + jest.spyOn(gqlClient, 'mutate').mockResolvedValue({ + data: { + issueMoveList: { + issue: {}, + errors: [{ foo: 'bar' }], + }, + }, + }); + + testAction( + actions.moveIssue, + { + issueId: '436', + issueIid: mockIssue.iid, + issuePath: mockIssue.referencePath, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + state, + [ + { + type: types.MOVE_ISSUE, + payload: { + originalIssue: mockIssueWithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }, + }, + { + type: types.MOVE_ISSUE_FAILURE, + payload: { + originalIssue: mockIssueWithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + originalIndex: 0, + }, + }, + ], + [], + done, + ); + }); }); describe('createNewIssue', () => { expectNotImplemented(actions.createNewIssue); }); +describe('addListIssue', () => { + it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => { + const payload = { + list: mockLists[0], + issue: mockIssue, + position: 0, + }; + + testAction( + actions.addListIssue, + payload, + {}, + [{ type: types.ADD_ISSUE_TO_LIST, payload }], + [], + done, + ); + }); +}); + +describe('addListIssueFailure', () => { + it('should commit UPDATE_LIST_FAILURE mutation when API returns an error', done => { + const payload = { + list: mockLists[0], + issue: mockIssue, + }; + + testAction( + actions.addListIssueFailure, + payload, + {}, + [{ type: types.ADD_ISSUE_TO_LIST_FAILURE, payload }], + [], + done, + ); + }); +}); + describe('fetchBacklog', () => { expectNotImplemented(actions.fetchBacklog); }); diff --git a/spec/frontend/boards/stores/getters_spec.js b/spec/frontend/boards/stores/getters_spec.js index 38b2333e679..288143a0f21 100644 --- a/spec/frontend/boards/stores/getters_spec.js +++ b/spec/frontend/boards/stores/getters_spec.js @@ -1,4 +1,6 @@ import getters from '~/boards/stores/getters'; +import { inactiveId } from '~/boards/constants'; +import { mockIssue, mockIssue2, mockIssues, mockIssuesByListId, issues } from '../mock_data'; describe('Boards - Getters', () => { describe('getLabelToggleState', () => { @@ -18,4 +20,114 @@ describe('Boards - Getters', () => { expect(getters.getLabelToggleState(state)).toBe('off'); }); }); + + describe('isSidebarOpen', () => { + it('returns true when activeId is not equal to 0', () => { + const state = { + activeId: 1, + }; + + expect(getters.isSidebarOpen(state)).toBe(true); + }); + + it('returns false when activeId is equal to 0', () => { + const state = { + activeId: inactiveId, + }; + + expect(getters.isSidebarOpen(state)).toBe(false); + }); + }); + + describe('isSwimlanesOn', () => { + afterEach(() => { + window.gon = { features: {} }; + }); + + describe('when boardsWithSwimlanes is true', () => { + beforeEach(() => { + window.gon = { features: { boardsWithSwimlanes: true } }; + }); + + describe('when isShowingEpicsSwimlanes is true', () => { + it('returns true', () => { + const state = { + isShowingEpicsSwimlanes: true, + }; + + expect(getters.isSwimlanesOn(state)).toBe(true); + }); + }); + + describe('when isShowingEpicsSwimlanes is false', () => { + it('returns false', () => { + const state = { + isShowingEpicsSwimlanes: false, + }; + + expect(getters.isSwimlanesOn(state)).toBe(false); + }); + }); + }); + + describe('when boardsWithSwimlanes is false', () => { + describe('when isShowingEpicsSwimlanes is true', () => { + it('returns false', () => { + const state = { + isShowingEpicsSwimlanes: true, + }; + + expect(getters.isSwimlanesOn(state)).toBe(false); + }); + }); + + describe('when isShowingEpicsSwimlanes is false', () => { + it('returns false', () => { + const state = { + isShowingEpicsSwimlanes: false, + }; + + expect(getters.isSwimlanesOn(state)).toBe(false); + }); + }); + }); + }); + + describe('getIssueById', () => { + const state = { issues: { '1': 'issue' } }; + + it.each` + id | expected + ${'1'} | ${'issue'} + ${''} | ${{}} + `('returns $expected when $id is passed to state', ({ id, expected }) => { + expect(getters.getIssueById(state)(id)).toEqual(expected); + }); + }); + + describe('getActiveIssue', () => { + it.each` + id | expected + ${'1'} | ${'issue'} + ${''} | ${{}} + `('returns $expected when $id is passed to state', ({ id, expected }) => { + const state = { issues: { '1': 'issue' }, activeId: id }; + + expect(getters.getActiveIssue(state)).toEqual(expected); + }); + }); + + describe('getIssues', () => { + const boardsState = { + issuesByListId: mockIssuesByListId, + issues, + }; + it('returns issues for a given listId', () => { + const getIssueById = issueId => [mockIssue, mockIssue2].find(({ id }) => id === issueId); + + expect(getters.getIssues(boardsState, { getIssueById })('gid://gitlab/List/2')).toEqual( + mockIssues, + ); + }); + }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index c1f7f3dda6e..a13a99a507e 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,6 +1,17 @@ import mutations from '~/boards/stores/mutations'; +import * as types from '~/boards/stores/mutation_types'; import defaultState from '~/boards/stores/state'; -import { mockIssue } from '../mock_data'; +import { + listObj, + listObjDuplicate, + mockListsWithModel, + mockLists, + rawIssue, + mockIssue, + mockIssue2, + mockIssueWithModel, + mockIssue2WithModel, +} from '../mock_data'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -26,21 +37,56 @@ describe('Board Store Mutations', () => { fullPath: 'gitlab-org', }; const boardType = 'group'; + const disabled = false; + const showPromotion = false; - mutations.SET_INITIAL_BOARD_DATA(state, { ...endpoints, boardType }); + mutations[types.SET_INITIAL_BOARD_DATA](state, { + ...endpoints, + boardType, + disabled, + showPromotion, + }); expect(state.endpoints).toEqual(endpoints); expect(state.boardType).toEqual(boardType); + expect(state.disabled).toEqual(disabled); + expect(state.showPromotion).toEqual(showPromotion); + }); + }); + + describe('RECEIVE_BOARD_LISTS_SUCCESS', () => { + it('Should set boardLists to state', () => { + const lists = [listObj, listObjDuplicate]; + + mutations[types.RECEIVE_BOARD_LISTS_SUCCESS](state, lists); + + expect(state.boardLists).toEqual(lists); }); }); describe('SET_ACTIVE_ID', () => { - it('updates activeListId to be the value that is passed', () => { - const expectedId = 1; + const expected = { id: 1, sidebarType: '' }; - mutations.SET_ACTIVE_ID(state, expectedId); + beforeEach(() => { + mutations.SET_ACTIVE_ID(state, expected); + }); + + it('updates aciveListId to be the value that is passed', () => { + expect(state.activeId).toBe(expected.id); + }); - expect(state.activeId).toBe(expectedId); + it('updates sidebarType to be the value that is passed', () => { + expect(state.sidebarType).toBe(expected.sidebarType); + }); + }); + + describe('SET_FILTERS', () => { + it('updates filterParams to be the value that is passed', () => { + const filterParams = { labelName: 'label' }; + + mutations.SET_FILTERS(state, filterParams); + + expect(state.filterParams).toBe(filterParams); }); }); @@ -56,16 +102,35 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_ADD_LIST_ERROR); }); - describe('REQUEST_UPDATE_LIST', () => { - expectNotImplemented(mutations.REQUEST_UPDATE_LIST); - }); + describe('MOVE_LIST', () => { + it('updates boardLists state with reordered lists', () => { + state = { + ...state, + boardLists: mockListsWithModel, + }; - describe('RECEIVE_UPDATE_LIST_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_SUCCESS); + mutations.MOVE_LIST(state, { + movedList: mockListsWithModel[0], + listAtNewIndex: mockListsWithModel[1], + }); + + expect(state.boardLists).toEqual([mockListsWithModel[1], mockListsWithModel[0]]); + }); }); - describe('RECEIVE_UPDATE_LIST_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_UPDATE_LIST_ERROR); + describe('UPDATE_LIST_FAILURE', () => { + it('updates boardLists state with previous order and sets error message', () => { + state = { + ...state, + boardLists: [mockListsWithModel[1], mockListsWithModel[0]], + error: undefined, + }; + + mutations.UPDATE_LIST_FAILURE(state, mockListsWithModel); + + expect(state.boardLists).toEqual(mockListsWithModel); + expect(state.error).toEqual('An error occurred while updating the list. Please try again.'); + }); }); describe('REQUEST_REMOVE_LIST', () => { @@ -80,6 +145,33 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); }); + describe('RECEIVE_ISSUES_FOR_LIST_SUCCESS', () => { + it('updates issuesByListId and issues on state', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id], + }; + const issues = { + '1': mockIssue, + }; + + state = { + ...state, + isLoadingIssues: true, + issuesByListId: {}, + issues: {}, + boardLists: mockListsWithModel, + }; + + mutations.RECEIVE_ISSUES_FOR_LIST_SUCCESS(state, { + listIssues: { listData: listIssues, issues }, + listId: 'gid://gitlab/List/1', + }); + + expect(state.issuesByListId).toEqual(listIssues); + expect(state.issues).toEqual(issues); + }); + }); + describe('REQUEST_ISSUES_FOR_ALL_LISTS', () => { it('sets isLoadingIssues to true', () => { expect(state.isLoadingIssues).toBe(false); @@ -90,22 +182,45 @@ describe('Board Store Mutations', () => { }); }); + describe('RECEIVE_ISSUES_FOR_LIST_FAILURE', () => { + it('sets error message', () => { + state = { + ...state, + boardLists: mockListsWithModel, + error: undefined, + }; + + const listId = 'gid://gitlab/List/1'; + + mutations.RECEIVE_ISSUES_FOR_LIST_FAILURE(state, listId); + + expect(state.error).toEqual( + 'An error occurred while fetching the board issues. Please reload the page.', + ); + }); + }); + describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => { it('sets isLoadingIssues to false and updates issuesByListId object', () => { const listIssues = { - '1': [mockIssue], + 'gid://gitlab/List/1': [mockIssue.id], + }; + const issues = { + '1': mockIssue, }; state = { ...state, isLoadingIssues: true, issuesByListId: {}, + issues: {}, }; - mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, listIssues); + mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, { listData: listIssues, issues }); expect(state.isLoadingIssues).toBe(false); expect(state.issuesByListId).toEqual(listIssues); + expect(state.issues).toEqual(issues); }); }); @@ -113,6 +228,65 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.REQUEST_ADD_ISSUE); }); + describe('RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE', () => { + it('sets isLoadingIssues to false and sets error message', () => { + state = { + ...state, + isLoadingIssues: true, + error: undefined, + }; + + mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_FAILURE(state); + + expect(state.isLoadingIssues).toBe(false); + expect(state.error).toEqual( + 'An error occurred while fetching the board issues. Please reload the page.', + ); + }); + }); + + describe('UPDATE_ISSUE_BY_ID', () => { + const issueId = '1'; + const prop = 'id'; + const value = '2'; + const issue = { [issueId]: { id: 1, title: 'Issue' } }; + + beforeEach(() => { + state = { + ...state, + isLoadingIssues: true, + error: undefined, + issues: { + ...issue, + }, + }; + }); + + describe('when the issue is in state', () => { + it('updates the property of the correct issue', () => { + mutations.UPDATE_ISSUE_BY_ID(state, { + issueId, + prop, + value, + }); + + expect(state.issues[issueId]).toEqual({ ...issue[issueId], id: '2' }); + }); + }); + + describe('when the issue is not in state', () => { + it('throws an error', () => { + expect(() => { + mutations.UPDATE_ISSUE_BY_ID(state, { + issueId: '3', + prop, + value, + }); + }).toThrow(new Error('No issue found.')); + }); + }); + }); + describe('RECEIVE_ADD_ISSUE_SUCCESS', () => { expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_SUCCESS); }); @@ -121,16 +295,86 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_ADD_ISSUE_ERROR); }); - describe('REQUEST_MOVE_ISSUE', () => { - expectNotImplemented(mutations.REQUEST_MOVE_ISSUE); + describe('MOVE_ISSUE', () => { + it('updates issuesByListId, moving issue between lists', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + 'gid://gitlab/List/2': [], + }; + + const issues = { + '1': mockIssueWithModel, + '2': mockIssue2WithModel, + }; + + state = { + ...state, + issuesByListId: listIssues, + boardLists: mockListsWithModel, + issues, + }; + + mutations.MOVE_ISSUE(state, { + originalIssue: mockIssue2WithModel, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + }); + + const updatedListIssues = { + 'gid://gitlab/List/1': [mockIssue.id], + 'gid://gitlab/List/2': [mockIssue2.id], + }; + + expect(state.issuesByListId).toEqual(updatedListIssues); + }); }); - describe('RECEIVE_MOVE_ISSUE_SUCCESS', () => { - expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_SUCCESS); + describe('MOVE_ISSUE_SUCCESS', () => { + it('updates issue in issues state', () => { + const issues = { + '436': { id: rawIssue.id }, + }; + + state = { + ...state, + issues, + }; + + mutations.MOVE_ISSUE_SUCCESS(state, { + issue: rawIssue, + }); + + expect(state.issues).toEqual({ '436': { ...mockIssueWithModel, id: 436 } }); + }); }); - describe('RECEIVE_MOVE_ISSUE_ERROR', () => { - expectNotImplemented(mutations.RECEIVE_MOVE_ISSUE_ERROR); + describe('MOVE_ISSUE_FAILURE', () => { + it('updates issuesByListId, reverting moving issue between lists, and sets error message', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id], + 'gid://gitlab/List/2': [mockIssue2.id], + }; + + state = { + ...state, + issuesByListId: listIssues, + }; + + mutations.MOVE_ISSUE_FAILURE(state, { + originalIssue: mockIssue2, + fromListId: 'gid://gitlab/List/1', + toListId: 'gid://gitlab/List/2', + originalIndex: 1, + }); + + const updatedListIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + 'gid://gitlab/List/2': [], + }; + + expect(state.issuesByListId).toEqual(updatedListIssues); + expect(state.error).toEqual('An error occurred while moving the issue. Please try again.'); + }); }); describe('REQUEST_UPDATE_ISSUE', () => { @@ -145,6 +389,50 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_UPDATE_ISSUE_ERROR); }); + describe('ADD_ISSUE_TO_LIST', () => { + it('adds issue to issues state and issue id in list in issuesByListId', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id], + }; + const issues = { + '1': mockIssue, + }; + + state = { + ...state, + issuesByListId: listIssues, + issues, + }; + + mutations.ADD_ISSUE_TO_LIST(state, { list: mockLists[0], issue: mockIssue2 }); + + expect(state.issuesByListId['gid://gitlab/List/1']).toContain(mockIssue2.id); + expect(state.issues[mockIssue2.id]).toEqual(mockIssue2); + }); + }); + + describe('ADD_ISSUE_TO_LIST_FAILURE', () => { + it('removes issue id from list in issuesByListId', () => { + const listIssues = { + 'gid://gitlab/List/1': [mockIssue.id, mockIssue2.id], + }; + const issues = { + '1': mockIssue, + '2': mockIssue2, + }; + + state = { + ...state, + issuesByListId: listIssues, + issues, + }; + + mutations.ADD_ISSUE_TO_LIST_FAILURE(state, { list: mockLists[0], issue: mockIssue2 }); + + expect(state.issuesByListId['gid://gitlab/List/1']).not.toContain(mockIssue2.id); + }); + }); + describe('SET_CURRENT_PAGE', () => { expectNotImplemented(mutations.SET_CURRENT_PAGE); }); diff --git a/spec/frontend/branches/ajax_loading_spinner_spec.js b/spec/frontend/branches/ajax_loading_spinner_spec.js new file mode 100644 index 00000000000..a6404faa445 --- /dev/null +++ b/spec/frontend/branches/ajax_loading_spinner_spec.js @@ -0,0 +1,32 @@ +import AjaxLoadingSpinner from '~/branches/ajax_loading_spinner'; + +describe('Ajax Loading Spinner', () => { + let ajaxLoadingSpinnerElement; + let fauxEvent; + beforeEach(() => { + document.body.innerHTML = ` + <div> + <a class="js-ajax-loading-spinner" + data-remote + href="http://goesnowhere.nothing/whereami"> + <i class="fa fa-trash-o"></i> + </a></div>`; + AjaxLoadingSpinner.init(); + ajaxLoadingSpinnerElement = document.querySelector('.js-ajax-loading-spinner'); + fauxEvent = { target: ajaxLoadingSpinnerElement }; + }); + + afterEach(() => { + document.body.innerHTML = ''; + }); + + it('`ajaxBeforeSend` event handler sets current icon to spinner and disables link', () => { + expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).toBeNull(); + expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(false); + + AjaxLoadingSpinner.ajaxBeforeSend(fauxEvent); + + expect(ajaxLoadingSpinnerElement.parentNode.querySelector('.gl-spinner')).not.toBeNull(); + expect(ajaxLoadingSpinnerElement.classList.contains('hidden')).toBe(true); + }); +}); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js index 4e35243f484..ab32fb12058 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_modal_spec.js @@ -49,7 +49,7 @@ describe('Ci variable modal', () => { }); it('does not render the autocomplete dropdown', () => { - expect(wrapper.contains(GlFormCombobox)).toBe(false); + expect(wrapper.find(GlFormCombobox).exists()).toBe(false); }); }); diff --git a/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap new file mode 100644 index 00000000000..5577176bcc5 --- /dev/null +++ b/spec/frontend/clusters/components/__snapshots__/new_cluster_spec.js.snap @@ -0,0 +1,8 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NewCluster renders the cluster component correctly 1`] = ` +"<div> + <h4>Enter the details for your Kubernetes cluster</h4> + <p>Please enter access information for your Kubernetes cluster. If you need help, you can read our <b-link-stub href=\\"/some/help/path\\" target=\\"_blank\\" event=\\"click\\" routertag=\\"a\\" class=\\"gl-link\\">documentation</b-link-stub> on Kubernetes</p> +</div>" +`; diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index b97d4dbf355..0a964426c95 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -48,7 +48,7 @@ describe('Application Row', () => { describe('Install button', () => { const button = () => wrapper.find('.js-cluster-application-install-button'); const checkButtonState = (label, loading, disabled) => { - expect(button().props('label')).toEqual(label); + expect(button().text()).toEqual(label); expect(button().props('loading')).toEqual(loading); expect(button().props('disabled')).toEqual(disabled); }; @@ -56,7 +56,7 @@ describe('Application Row', () => { it('has indeterminate state on page load', () => { mountComponent({ status: null }); - expect(button().props('label')).toBeUndefined(); + expect(button().text()).toBe(''); }); it('has install button', () => { @@ -225,7 +225,7 @@ describe('Application Row', () => { mountComponent({ updateAvailable: true }); expect(button().exists()).toBe(true); - expect(button().props('label')).toContain('Update'); + expect(button().text()).toContain('Update'); }); it('has enabled "Retry update" when update process fails', () => { @@ -235,14 +235,14 @@ describe('Application Row', () => { }); expect(button().exists()).toBe(true); - expect(button().props('label')).toContain('Retry update'); + expect(button().text()).toContain('Retry update'); }); it('has disabled "Updating" when APPLICATION_STATUS.UPDATING', () => { mountComponent({ status: APPLICATION_STATUS.UPDATING }); expect(button().exists()).toBe(true); - expect(button().props('label')).toContain('Updating'); + expect(button().text()).toContain('Updating'); }); it('clicking update button emits event', () => { @@ -300,11 +300,11 @@ describe('Application Row', () => { beforeEach(() => mountComponent({ updateAvailable: true })); it('the modal is not rendered', () => { - expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false); + expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false); }); it('the correct button is rendered', () => { - expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true); + expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true); }); }); @@ -318,11 +318,13 @@ describe('Application Row', () => { }); it('displays a modal', () => { - expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true); + expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(true); }); it('the correct button is rendered', () => { - expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true); + expect(wrapper.find("[data-qa-selector='update_button_with_confirmation']").exists()).toBe( + true, + ); }); it('triggers updateApplication event', () => { @@ -344,8 +346,10 @@ describe('Application Row', () => { version: '1.1.2', }); - expect(wrapper.contains("[data-qa-selector='update_button_with_confirmation']")).toBe(true); - expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(true); + expect(wrapper.find("[data-qa-selector='update_button_with_confirmation']").exists()).toBe( + true, + ); + expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(true); }); it('does not need confirmation is version is 3.0.0', () => { @@ -355,8 +359,8 @@ describe('Application Row', () => { version: '3.0.0', }); - expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true); - expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false); + expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true); + expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false); }); it('does not need confirmation if version is higher than 3.0.0', () => { @@ -366,8 +370,8 @@ describe('Application Row', () => { version: '5.2.1', }); - expect(wrapper.contains("[data-qa-selector='update_button']")).toBe(true); - expect(wrapper.contains(UpdateApplicationConfirmationModal)).toBe(false); + expect(wrapper.find("[data-qa-selector='update_button']").exists()).toBe(true); + expect(wrapper.find(UpdateApplicationConfirmationModal).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js index 0bc4eb73bf9..c263679a45c 100644 --- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js +++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js @@ -168,7 +168,7 @@ describe('FluentdOutputSettings', () => { }); it('displays a error message', () => { - expect(wrapper.contains(GlAlert)).toBe(true); + expect(wrapper.find(GlAlert).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js index a07258dcc69..11ebe1b5d61 100644 --- a/spec/frontend/clusters/components/knative_domain_editor_spec.js +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem, GlButton } from '@gitlab/ui'; import KnativeDomainEditor from '~/clusters/components/knative_domain_editor.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS } from '~/clusters/constants'; const { UPDATING } = APPLICATION_STATUS; @@ -79,7 +78,7 @@ describe('KnativeDomainEditor', () => { }); it('triggers save event and pass current knative hostname', () => { - wrapper.find(LoadingButton).vm.$emit('click'); + wrapper.find(GlButton).vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('save').length).toEqual(1); }); @@ -166,15 +165,15 @@ describe('KnativeDomainEditor', () => { }); it('renders loading spinner in save button', () => { - expect(wrapper.find(LoadingButton).props('loading')).toBe(true); + expect(wrapper.find(GlButton).props('loading')).toBe(true); }); it('renders disabled save button', () => { - expect(wrapper.find(LoadingButton).props('disabled')).toBe(true); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); }); it('renders save button with "Saving" label', () => { - expect(wrapper.find(LoadingButton).props('label')).toBe('Saving'); + expect(wrapper.find(GlButton).text()).toBe('Saving'); }); }); }); diff --git a/spec/frontend/clusters/components/new_cluster_spec.js b/spec/frontend/clusters/components/new_cluster_spec.js new file mode 100644 index 00000000000..bb4898f98ba --- /dev/null +++ b/spec/frontend/clusters/components/new_cluster_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import NewCluster from '~/clusters/components/new_cluster.vue'; +import createClusterStore from '~/clusters/stores/new_cluster'; + +describe('NewCluster', () => { + let store; + let wrapper; + + const createWrapper = () => { + store = createClusterStore({ clusterConnectHelpPath: '/some/help/path' }); + wrapper = shallowMount(NewCluster, { store, stubs: { GlLink, GlSprintf } }); + return wrapper.vm.$nextTick(); + }; + + const findDescription = () => wrapper.find(GlSprintf); + + const findLink = () => wrapper.find(GlLink); + + beforeEach(() => { + return createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the cluster component correctly', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); + + it('renders the correct information text', () => { + expect(findDescription().text()).toContain( + 'Please enter access information for your Kubernetes cluster.', + ); + }); + + it('renders a valid help link set by the backend', () => { + expect(findLink().attributes('href')).toBe('/some/help/path'); + }); +}); diff --git a/spec/frontend/clusters/components/uninstall_application_button_spec.js b/spec/frontend/clusters/components/uninstall_application_button_spec.js index 9f9397d4d41..387e2188572 100644 --- a/spec/frontend/clusters/components/uninstall_application_button_spec.js +++ b/spec/frontend/clusters/components/uninstall_application_button_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import UninstallApplicationButton from '~/clusters/components/uninstall_application_button.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import { APPLICATION_STATUS } from '~/clusters/constants'; const { INSTALLED, UPDATING, UNINSTALLING } = APPLICATION_STATUS; @@ -19,14 +19,21 @@ describe('UninstallApplicationButton', () => { }); describe.each` - status | loading | disabled | label + status | loading | disabled | text ${INSTALLED} | ${false} | ${false} | ${'Uninstall'} ${UPDATING} | ${false} | ${true} | ${'Uninstall'} ${UNINSTALLING} | ${true} | ${true} | ${'Uninstalling'} - `('when app status is $status', ({ loading, disabled, status, label }) => { - it(`renders a loading=${loading}, disabled=${disabled} button with label="${label}"`, () => { + `('when app status is $status', ({ loading, disabled, status, text }) => { + beforeAll(() => { createComponent({ status }); - expect(wrapper.find(LoadingButton).props()).toMatchObject({ loading, disabled, label }); + }); + + it(`renders a button with loading=${loading} and disabled=${disabled}`, () => { + expect(wrapper.find(GlButton).props()).toMatchObject({ loading, disabled }); + }); + + it(`renders a button with text="${text}"`, () => { + expect(wrapper.find(GlButton).text()).toBe(text); }); }); }); diff --git a/spec/frontend/clusters_list/components/ancestor_notice_spec.js b/spec/frontend/clusters_list/components/ancestor_notice_spec.js index cff84180f26..436f1e97b04 100644 --- a/spec/frontend/clusters_list/components/ancestor_notice_spec.js +++ b/spec/frontend/clusters_list/components/ancestor_notice_spec.js @@ -28,7 +28,7 @@ describe('ClustersAncestorNotice', () => { }); it('displays no notice', () => { - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); }); @@ -45,7 +45,7 @@ describe('ClustersAncestorNotice', () => { }); it('displays link', () => { - expect(wrapper.contains(GlLink)).toBe(true); + expect(wrapper.find(GlLink).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index c6a5f66a627..628c35ae839 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -1,6 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import { mount } from '@vue/test-utils'; -import { GlLoadingIcon, GlPagination, GlSkeletonLoading, GlTable } from '@gitlab/ui'; +import { + GlLoadingIcon, + GlPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, + GlTable, +} from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import axios from '~/lib/utils/axios_utils'; import Clusters from '~/clusters_list/components/clusters.vue'; diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js index 0c74491aa74..b1a304fabcd 100644 --- a/spec/frontend/collapsed_sidebar_todo_spec.js +++ b/spec/frontend/collapsed_sidebar_todo_spec.js @@ -47,9 +47,9 @@ describe('Issuable right sidebar collapsed todo toggle', () => { expect( document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg use') - .getAttribute('xlink:href'), - ).toContain('todo-add'); + .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg') + .getAttribute('data-testid'), + ).toBe('todo-add-icon'); expect( document.querySelector('.js-issuable-todo.sidebar-collapsed-icon .todo-undone'), @@ -72,9 +72,9 @@ describe('Issuable right sidebar collapsed todo toggle', () => { expect( document - .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone use') - .getAttribute('xlink:href'), - ).toContain('todo-done'); + .querySelector('.js-issuable-todo.sidebar-collapsed-icon svg.todo-undone') + .getAttribute('data-testid'), + ).toBe('todo-done-icon'); done(); }); diff --git a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap index f7b68d96129..271c6356f7e 100644 --- a/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap +++ b/spec/frontend/confidential_merge_request/components/__snapshots__/project_form_group_spec.js.snap @@ -33,9 +33,9 @@ exports[`Confidential merge request project form group component renders empty s Read more </span> - <i - aria-hidden="true" - class="fa fa-question-circle" + <gl-icon-stub + name="question-o" + size="16" /> </gl-link-stub> </p> @@ -76,9 +76,9 @@ exports[`Confidential merge request project form group component renders fork dr Read more </span> - <i - aria-hidden="true" - class="fa fa-question-circle" + <gl-icon-stub + name="question-o" + size="16" /> </gl-link-stub> </p> diff --git a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js index 14f2a527dfb..17abf409717 100644 --- a/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js +++ b/spec/frontend/create_cluster/components/cluster_form_dropdown_spec.js @@ -92,7 +92,7 @@ describe('ClusterFormDropdown', () => { }); it('displays a checked GlIcon next to the item', () => { - expect(wrapper.find(GlIcon).is('.invisible')).toBe(false); + expect(wrapper.find(GlIcon).classes()).not.toContain('invisible'); expect(wrapper.find(GlIcon).props('name')).toBe('mobile-issue-close'); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js index 34d9ee733c4..d7dd7072f67 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/eks_cluster_configuration_form_spec.js @@ -147,6 +147,7 @@ describe('EksClusterConfigurationForm', () => { initialState: { clusterName: 'cluster name', environmentScope: '*', + kubernetesVersion: '1.16', selectedRegion: 'region', selectedRole: 'role', selectedKeyPair: 'key pair', @@ -400,43 +401,33 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setRegion action', () => { - expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region }, undefined); + expect(actions.setRegion).toHaveBeenCalledWith(expect.anything(), { region }); }); it('fetches available vpcs', () => { - expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }, undefined); + expect(vpcsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); }); it('fetches available key pairs', () => { - expect(keyPairsActions.fetchItems).toHaveBeenCalledWith( - expect.anything(), - { region }, - undefined, - ); + expect(keyPairsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { region }); }); it('cleans selected vpc', () => { - expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }, undefined); + expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc: null }); }); it('cleans selected key pair', () => { - expect(actions.setKeyPair).toHaveBeenCalledWith( - expect.anything(), - { keyPair: null }, - undefined, - ); + expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair: null }); }); it('cleans selected subnet', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }, undefined); + expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }); }); it('cleans selected security group', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith( - expect.anything(), - { securityGroup: null }, - undefined, - ); + expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { + securityGroup: null, + }); }); }); @@ -445,11 +436,7 @@ describe('EksClusterConfigurationForm', () => { findClusterNameInput().vm.$emit('input', clusterName); - expect(actions.setClusterName).toHaveBeenCalledWith( - expect.anything(), - { clusterName }, - undefined, - ); + expect(actions.setClusterName).toHaveBeenCalledWith(expect.anything(), { clusterName }); }); it('dispatches setEnvironmentScope when environment scope input changes', () => { @@ -457,11 +444,9 @@ describe('EksClusterConfigurationForm', () => { findEnvironmentScopeInput().vm.$emit('input', environmentScope); - expect(actions.setEnvironmentScope).toHaveBeenCalledWith( - expect.anything(), - { environmentScope }, - undefined, - ); + expect(actions.setEnvironmentScope).toHaveBeenCalledWith(expect.anything(), { + environmentScope, + }); }); it('dispatches setKubernetesVersion when kubernetes version dropdown changes', () => { @@ -469,11 +454,9 @@ describe('EksClusterConfigurationForm', () => { findKubernetesVersionDropdown().vm.$emit('input', kubernetesVersion); - expect(actions.setKubernetesVersion).toHaveBeenCalledWith( - expect.anything(), - { kubernetesVersion }, - undefined, - ); + expect(actions.setKubernetesVersion).toHaveBeenCalledWith(expect.anything(), { + kubernetesVersion, + }); }); it('dispatches setGitlabManagedCluster when gitlab managed cluster input changes', () => { @@ -481,11 +464,9 @@ describe('EksClusterConfigurationForm', () => { findGitlabManagedClusterCheckbox().vm.$emit('input', gitlabManagedCluster); - expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith( - expect.anything(), - { gitlabManagedCluster }, - undefined, - ); + expect(actions.setGitlabManagedCluster).toHaveBeenCalledWith(expect.anything(), { + gitlabManagedCluster, + }); }); describe('when vpc is selected', () => { @@ -498,35 +479,28 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setVpc action', () => { - expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }, undefined); + expect(actions.setVpc).toHaveBeenCalledWith(expect.anything(), { vpc }); }); it('cleans selected subnet', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }, undefined); + expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet: [] }); }); it('cleans selected security group', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith( - expect.anything(), - { securityGroup: null }, - undefined, - ); + expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { + securityGroup: null, + }); }); it('dispatches fetchSubnets action', () => { - expect(subnetsActions.fetchItems).toHaveBeenCalledWith( - expect.anything(), - { vpc, region }, - undefined, - ); + expect(subnetsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { vpc, region }); }); it('dispatches fetchSecurityGroups action', () => { - expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith( - expect.anything(), - { vpc, region }, - undefined, - ); + expect(securityGroupsActions.fetchItems).toHaveBeenCalledWith(expect.anything(), { + vpc, + region, + }); }); }); @@ -538,7 +512,7 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setSubnet action', () => { - expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet }, undefined); + expect(actions.setSubnet).toHaveBeenCalledWith(expect.anything(), { subnet }); }); }); @@ -550,7 +524,7 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setRole action', () => { - expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role }, undefined); + expect(actions.setRole).toHaveBeenCalledWith(expect.anything(), { role }); }); }); @@ -562,7 +536,7 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setKeyPair action', () => { - expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair }, undefined); + expect(actions.setKeyPair).toHaveBeenCalledWith(expect.anything(), { keyPair }); }); }); @@ -574,11 +548,7 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setSecurityGroup action', () => { - expect(actions.setSecurityGroup).toHaveBeenCalledWith( - expect.anything(), - { securityGroup }, - undefined, - ); + expect(actions.setSecurityGroup).toHaveBeenCalledWith(expect.anything(), { securityGroup }); }); }); @@ -590,11 +560,7 @@ describe('EksClusterConfigurationForm', () => { }); it('dispatches setInstanceType action', () => { - expect(actions.setInstanceType).toHaveBeenCalledWith( - expect.anything(), - { instanceType }, - undefined, - ); + expect(actions.setInstanceType).toHaveBeenCalledWith(expect.anything(), { instanceType }); }); }); @@ -603,7 +569,7 @@ describe('EksClusterConfigurationForm', () => { findNodeCountInput().vm.$emit('input', nodeCount); - expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }, undefined); + expect(actions.setNodeCount).toHaveBeenCalledWith(expect.anything(), { nodeCount }); }); describe('when all cluster configuration fields are set', () => { diff --git a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js index c58638f5c80..d2d6db31d1b 100644 --- a/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/components/service_credentials_form_spec.js @@ -1,9 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; - +import { GlButton } from '@gitlab/ui'; import ServiceCredentialsForm from '~/create_cluster/eks_cluster/components/service_credentials_form.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; - import eksClusterState from '~/create_cluster/eks_cluster/store/state'; const localVue = createLocalVue(); @@ -46,7 +44,7 @@ describe('ServiceCredentialsForm', () => { const findExternalIdInput = () => vm.find('#eks-external-id'); const findCopyExternalIdButton = () => vm.find('.js-copy-external-id-button'); const findInvalidCredentials = () => vm.find('.js-invalid-credentials'); - const findSubmitButton = () => vm.find(LoadingButton); + const findSubmitButton = () => vm.find(GlButton); it('displays provided account id', () => { expect(findAccountIdInput().attributes('value')).toBe(accountId); @@ -102,7 +100,7 @@ describe('ServiceCredentialsForm', () => { }); it('displays Authenticating label on submit button', () => { - expect(findSubmitButton().props('label')).toBe('Authenticating'); + expect(findSubmitButton().text()).toBe('Authenticating'); }); }); diff --git a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js index 882a4a002bd..ed753888790 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -47,7 +47,7 @@ describe('EKS Cluster Store Actions', () => { beforeEach(() => { clusterName = 'my cluster'; environmentScope = 'production'; - kubernetesVersion = '11.1'; + kubernetesVersion = '1.16'; region = 'regions-1'; vpc = 'vpc-1'; subnet = 'subnet-1'; @@ -180,6 +180,7 @@ describe('EKS Cluster Store Actions', () => { environment_scope: environmentScope, managed: gitlabManagedCluster, provider_aws_attributes: { + kubernetes_version: kubernetesVersion, region, vpc_id: vpc, subnet_ids: subnet, diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js index 57ef74f0119..c09eaa63d4d 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_machine_type_dropdown_spec.js @@ -124,11 +124,7 @@ describe('GkeMachineTypeDropdown', () => { wrapper.find('.dropdown-content button').trigger('click'); return wrapper.vm.$nextTick().then(() => { - expect(setMachineType).toHaveBeenCalledWith( - expect.anything(), - selectedMachineTypeMock, - undefined, - ); + expect(setMachineType).toHaveBeenCalledWith(expect.anything(), selectedMachineTypeMock); }); }); }); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js index 1df583af711..ce24d186511 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_network_dropdown_spec.js @@ -121,23 +121,19 @@ describe('GkeNetworkDropdown', () => { }); it('cleans selected subnetwork', () => { - expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), '', undefined); + expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), ''); }); it('dispatches the setNetwork action', () => { - expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork, undefined); + expect(setNetwork).toHaveBeenCalledWith(expect.anything(), selectedNetwork); }); it('fetches subnetworks for the selected project, region, and network', () => { - expect(fetchSubnetworks).toHaveBeenCalledWith( - expect.anything(), - { - project: projectId, - region, - network: selectedNetwork.selfLink, - }, - undefined, - ); + expect(fetchSubnetworks).toHaveBeenCalledWith(expect.anything(), { + project: projectId, + region, + network: selectedNetwork.selfLink, + }); }); }); }); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js index 0d429778a44..eb58108bf3c 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_project_id_dropdown_spec.js @@ -130,7 +130,6 @@ describe('GkeProjectIdDropdown', () => { expect(setProject).toHaveBeenCalledWith( expect.anything(), gapiProjectsResponseMock.projects[0], - undefined, ); }); }); diff --git a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js index a1dc3960fe9..35e43d5b033 100644 --- a/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js +++ b/spec/frontend/create_cluster/gke_cluster/components/gke_subnetwork_dropdown_spec.js @@ -107,7 +107,7 @@ describe('GkeSubnetworkDropdown', () => { wrapper.find(ClusterFormDropdown).vm.$emit('input', selectedSubnetwork); - expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork, undefined); + expect(setSubnetwork).toHaveBeenCalledWith(expect.anything(), selectedSubnetwork); }); }); }); diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js index 644cd0b5f27..99cb864ce34 100644 --- a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js +++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedDropdownItem, GlNewDropdown } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem, GlDropdown } from '@gitlab/ui'; import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; import createStore from '~/deploy_freeze/store'; @@ -92,7 +92,7 @@ describe('Deploy freeze timezone dropdown', () => { }); it('renders selected time zone as dropdown label', () => { - expect(wrapper.find(GlNewDropdown).vm.text).toBe('Alaska'); + expect(wrapper.find(GlDropdown).vm.text).toBe('Alaska'); }); }); }); diff --git a/spec/frontend/deploy_keys/components/key_spec.js b/spec/frontend/deploy_keys/components/key_spec.js index 7d942d969bb..0b1cbd28274 100644 --- a/spec/frontend/deploy_keys/components/key_spec.js +++ b/spec/frontend/deploy_keys/components/key_spec.js @@ -55,20 +55,20 @@ describe('Deploy keys key', () => { it('shows pencil button for editing', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn .ic-pencil')).toExist(); + expect(wrapper.find('.btn [data-testid="pencil-icon"]')).toExist(); }); it('shows disable button when the project is not deletable', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn .ic-cancel')).toExist(); + expect(wrapper.find('.btn [data-testid="cancel-icon"]')).toExist(); }); it('shows remove button when the project is deletable', () => { createComponent({ deployKey: { ...deployKey, destroyed_when_orphaned: true, almost_orphaned: true }, }); - expect(wrapper.find('.btn .ic-remove')).toExist(); + expect(wrapper.find('.btn [data-testid="remove-icon"]')).toExist(); }); }); @@ -147,7 +147,7 @@ describe('Deploy keys key', () => { it('shows pencil button for editing', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn .ic-pencil')).toExist(); + expect(wrapper.find('.btn [data-testid="pencil-icon"]')).toExist(); }); it('shows disable button when key is enabled', () => { @@ -155,7 +155,7 @@ describe('Deploy keys key', () => { createComponent({ deployKey }); - expect(wrapper.find('.btn .ic-cancel')).toExist(); + expect(wrapper.find('.btn [data-testid="cancel-icon"]')).toExist(); }); }); }); diff --git a/spec/frontend/gl_dropdown_spec.js b/spec/frontend/deprecated_jquery_dropdown_spec.js index 8bfe7f56e37..e6323859899 100644 --- a/spec/frontend/gl_dropdown_spec.js +++ b/spec/frontend/deprecated_jquery_dropdown_spec.js @@ -1,7 +1,7 @@ /* eslint-disable no-param-reassign */ import $ from 'jquery'; -import '~/gl_dropdown'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; import '~/lib/utils/common_utils'; import { visitUrl } from '~/lib/utils/url_utility'; @@ -9,8 +9,8 @@ jest.mock('~/lib/utils/url_utility', () => ({ visitUrl: jest.fn().mockName('visitUrl'), })); -describe('glDropdown', () => { - preloadFixtures('static/gl_dropdown.html'); +describe('deprecatedJQueryDropdown', () => { + preloadFixtures('static/deprecated_jquery_dropdown.html'); const NON_SELECTABLE_CLASSES = '.divider, .separator, .dropdown-header, .dropdown-menu-empty-item'; @@ -60,14 +60,12 @@ describe('glDropdown', () => { id: project => project.id, ...extraOpts, }; - test.dropdownButtonElement = $( - '#js-project-dropdown', - test.dropdownContainerElement, - ).glDropdown(options); + test.dropdownButtonElement = $('#js-project-dropdown', test.dropdownContainerElement); + initDeprecatedJQueryDropdown(test.dropdownButtonElement, options); } beforeEach(() => { - loadFixtures('static/gl_dropdown.html'); + loadFixtures('static/deprecated_jquery_dropdown.html'); test.dropdownContainerElement = $('.dropdown.inline'); test.$dropdownMenuElement = $('.dropdown-menu', test.dropdownContainerElement); test.projectsData = getJSONFixture('static/projects.json'); @@ -248,9 +246,9 @@ describe('glDropdown', () => { function dropdownWithOptions(options) { const $dropdownDiv = $('<div />'); - $dropdownDiv.glDropdown(options); + initDeprecatedJQueryDropdown($dropdownDiv, options); - return $dropdownDiv.data('glDropdown'); + return $dropdownDiv.data('deprecatedJQueryDropdown'); } function basicDropdown() { @@ -315,6 +313,42 @@ describe('glDropdown', () => { expect(li.childNodes.length).toEqual(1); expect(li.textContent).toEqual(text); }); + + describe('with a trackSuggestionsClickedLabel', () => { + it('it includes data-track attributes', () => { + const dropdown = dropdownWithOptions({ + trackSuggestionClickedLabel: 'some_value_for_label', + }); + const item = { + id: 'some-element-id', + text: 'the link text', + url: 'http://example.com', + category: 'Suggestion category', + }; + const li = dropdown.renderItem(item, null, 3); + const link = li.querySelector('a'); + + expect(link).toHaveAttr('data-track-event', 'click_text'); + expect(link).toHaveAttr('data-track-label', 'some_value_for_label'); + expect(link).toHaveAttr('data-track-value', '3'); + expect(link).toHaveAttr('data-track-property', 'suggestion-category'); + }); + + it('it defaults property to no_category when category not provided', () => { + const dropdown = dropdownWithOptions({ + trackSuggestionClickedLabel: 'some_value_for_label', + }); + const item = { + id: 'some-element-id', + text: 'the link text', + url: 'http://example.com', + }; + const li = dropdown.renderItem(item); + const link = li.querySelector('a'); + + expect(link).toHaveAttr('data-track-property', 'no-category'); + }); + }); }); it('should keep selected item after selecting a second time', () => { diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js index cd4ef1f0ccd..961f5bdd2ae 100644 --- a/spec/frontend/design_management/components/delete_button_spec.js +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -8,7 +8,7 @@ describe('Batch delete button component', () => { const findButton = () => wrapper.find(GlButton); const findModal = () => wrapper.find(GlModal); - function createComponent(isDeleting = false) { + function createComponent({ isDeleting = false } = {}, { slots = {} } = {}) { wrapper = shallowMount(BatchDeleteButton, { propsData: { isDeleting, @@ -16,6 +16,7 @@ describe('Batch delete button component', () => { directives: { GlModalDirective, }, + slots, }); } @@ -31,7 +32,7 @@ describe('Batch delete button component', () => { }); it('renders disabled button when design is deleting', () => { - createComponent(true); + createComponent({ isDeleting: true }); expect(findButton().attributes('disabled')).toBeTruthy(); }); @@ -48,4 +49,18 @@ describe('Batch delete button component', () => { expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy(); }); }); + + it('renders slot content', () => { + const testText = 'Archive selected'; + createComponent( + {}, + { + slots: { + default: testText, + }, + }, + ); + + expect(findButton().text()).toBe(testText); + }); }); diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap index b55bacb6fc5..084a7e5d712 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_note_spec.js.snap @@ -17,15 +17,15 @@ exports[`Design note component should match the snapshot 1`] = ` /> <div - class="d-flex justify-content-between" + class="gl-display-flex gl-justify-content-space-between" > <div> - <a + <gl-link-stub class="js-user-link" data-user-id="author-id" > <span - class="note-header-author-name bold" + class="note-header-author-name gl-font-weight-bold" > </span> @@ -37,7 +37,7 @@ exports[`Design note component should match the snapshot 1`] = ` > @ </span> - </a> + </gl-link-stub> <span class="note-headline-light note-headline-meta" @@ -46,12 +46,21 @@ exports[`Design note component should match the snapshot 1`] = ` class="system-note-message" /> - <!----> + <gl-link-stub + class="note-timestamp system-note-separator gl-display-block gl-mb-2" + href="#note_123" + > + <time-ago-tooltip-stub + cssclass="" + time="2019-07-26T15:02:20Z" + tooltipplacement="bottom" + /> + </gl-link-stub> </span> </div> <div - class="gl-display-flex" + class="gl-display-flex gl-align-items-baseline" > <!----> diff --git a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap index e01c79e3520..f8c68ca4c83 100644 --- a/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ b/spec/frontend/design_management/components/design_notes/__snapshots__/design_reply_form_spec.js.snap @@ -1,15 +1,17 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\"> <!----> - Comment -</button>" + <!----> <span class=\\"gl-button-text\\"> + Comment + </span></button>" `; exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> +"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled gl-button\\"> <!----> - Save comment -</button>" + <!----> <span class=\\"gl-button-text\\"> + Save comment + </span></button>" `; diff --git a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js index 176c10ea584..9fbd9b2c2a3 100644 --- a/spec/frontend/design_management/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_discussion_spec.js @@ -8,8 +8,9 @@ import createNoteMutation from '~/design_management/graphql/mutations/create_not import toggleResolveDiscussionMutation from '~/design_management/graphql/mutations/toggle_resolve_discussion.mutation.graphql'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import ToggleRepliesWidget from '~/design_management/components/design_notes/toggle_replies_widget.vue'; +import mockDiscussion from '../../mock_data/discussion'; -const discussion = { +const defaultMockDiscussion = { id: '0', resolved: false, resolvable: true, @@ -31,7 +32,6 @@ describe('Design discussions component', () => { const mutationVariables = { mutation: createNoteMutation, - update: expect.anything(), variables: { input: { noteableId: 'noteable-id', @@ -40,7 +40,7 @@ describe('Design discussions component', () => { }, }, }; - const mutate = jest.fn(() => Promise.resolve()); + const mutate = jest.fn().mockResolvedValue({ data: { createNote: { errors: [] } } }); const $apollo = { mutate, }; @@ -49,7 +49,7 @@ describe('Design discussions component', () => { wrapper = mount(DesignDiscussion, { propsData: { resolvedDiscussionsExpanded: true, - discussion, + discussion: defaultMockDiscussion, noteableId: 'noteable-id', designId: 'design-id', discussionIndex: 1, @@ -82,7 +82,7 @@ describe('Design discussions component', () => { beforeEach(() => { createComponent({ discussion: { - ...discussion, + ...defaultMockDiscussion, resolvable: false, }, }); @@ -93,7 +93,7 @@ describe('Design discussions component', () => { }); it('does not render a checkbox in reply form', () => { - findReplyPlaceholder().vm.$emit('onMouseDown'); + findReplyPlaceholder().vm.$emit('onClick'); return wrapper.vm.$nextTick().then(() => { expect(findResolveCheckbox().exists()).toBe(false); @@ -125,7 +125,7 @@ describe('Design discussions component', () => { it('renders a checkbox with Resolve thread text in reply form', () => { findReplyPlaceholder().vm.$emit('onClick'); - wrapper.setProps({ discussionWithOpenForm: discussion.id }); + wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { expect(findResolveCheckbox().text()).toBe('Resolve thread'); @@ -141,7 +141,7 @@ describe('Design discussions component', () => { beforeEach(() => { createComponent({ discussion: { - ...discussion, + ...defaultMockDiscussion, resolved: true, resolvedBy: notes[0].author, resolvedAt: '2020-05-08T07:10:45Z', @@ -206,7 +206,7 @@ describe('Design discussions component', () => { it('renders a checkbox with Unresolve thread text in reply form', () => { findReplyPlaceholder().vm.$emit('onClick'); - wrapper.setProps({ discussionWithOpenForm: discussion.id }); + wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { expect(findResolveCheckbox().text()).toBe('Unresolve thread'); @@ -218,7 +218,7 @@ describe('Design discussions component', () => { it('hides reply placeholder and opens form on placeholder click', () => { createComponent(); findReplyPlaceholder().vm.$emit('onClick'); - wrapper.setProps({ discussionWithOpenForm: discussion.id }); + wrapper.setProps({ discussionWithOpenForm: defaultMockDiscussion.id }); return wrapper.vm.$nextTick().then(() => { expect(findReplyPlaceholder().exists()).toBe(false); @@ -226,34 +226,31 @@ describe('Design discussions component', () => { }); }); - it('calls mutation on submitting form and closes the form', () => { + it('calls mutation on submitting form and closes the form', async () => { createComponent( - { discussionWithOpenForm: discussion.id }, + { discussionWithOpenForm: defaultMockDiscussion.id }, { discussionComment: 'test', isFormRendered: true }, ); - findReplyForm().vm.$emit('submitForm'); + findReplyForm().vm.$emit('submit-form'); expect(mutate).toHaveBeenCalledWith(mutationVariables); - return mutate() - .then(() => { - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findReplyForm().exists()).toBe(false); - }); + await mutate(); + await wrapper.vm.$nextTick(); + + expect(findReplyForm().exists()).toBe(false); }); it('clears the discussion comment on closing comment form', () => { createComponent( - { discussionWithOpenForm: discussion.id }, + { discussionWithOpenForm: defaultMockDiscussion.id }, { discussionComment: 'test', isFormRendered: true }, ); return wrapper.vm .$nextTick() .then(() => { - findReplyForm().vm.$emit('cancelForm'); + findReplyForm().vm.$emit('cancel-form'); expect(wrapper.vm.discussionComment).toBe(''); return wrapper.vm.$nextTick(); @@ -263,19 +260,26 @@ describe('Design discussions component', () => { }); }); - it('applies correct class to design notes when discussion is highlighted', () => { - createComponent( - {}, - { - activeDiscussion: { - id: notes[0].id, - source: 'pin', - }, - }, - ); + describe('when any note from a discussion is active', () => { + it.each([notes[0], notes[0].discussion.notes.nodes[1]])( + 'applies correct class to all notes in the active discussion', + note => { + createComponent( + { discussion: mockDiscussion }, + { + activeDiscussion: { + id: note.id, + source: 'pin', + }, + }, + ); - expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe( - true, + expect( + wrapper + .findAll(DesignNote) + .wrappers.every(designNote => designNote.classes('gl-bg-blue-50')), + ).toBe(true); + }, ); }); @@ -285,7 +289,7 @@ describe('Design discussions component', () => { expect(mutate).toHaveBeenCalledWith({ mutation: toggleResolveDiscussionMutation, variables: { - id: discussion.id, + id: defaultMockDiscussion.id, resolve: true, }, }); @@ -296,7 +300,7 @@ describe('Design discussions component', () => { it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => { createComponent( - { discussionWithOpenForm: discussion.id }, + { discussionWithOpenForm: defaultMockDiscussion.id }, { discussionComment: 'test', isFormRendered: true }, ); findResolveButton().trigger('click'); @@ -306,7 +310,7 @@ describe('Design discussions component', () => { expect(mutate).toHaveBeenCalledWith({ mutation: toggleResolveDiscussionMutation, variables: { - id: discussion.id, + id: defaultMockDiscussion.id, resolve: true, }, }); @@ -317,6 +321,6 @@ describe('Design discussions component', () => { createComponent(); findReplyPlaceholder().vm.$emit('onClick'); - expect(wrapper.emitted('openForm')).toBeTruthy(); + expect(wrapper.emitted('open-form')).toBeTruthy(); }); }); diff --git a/spec/frontend/design_management/components/design_notes/design_note_spec.js b/spec/frontend/design_management/components/design_notes/design_note_spec.js index 8b32d3022ee..043091e3dc2 100644 --- a/spec/frontend/design_management/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_note_spec.js @@ -15,6 +15,7 @@ const note = { userPermissions: { adminNote: false, }, + createdAt: '2019-07-26T15:02:20Z', }; HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; @@ -79,21 +80,10 @@ describe('Design note component', () => { it('should render a time ago tooltip if note has createdAt property', () => { createComponent({ - note: { - ...note, - createdAt: '2019-07-26T15:02:20Z', - }, - }); - - expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); - }); - - it('should trigger a scrollIntoView method', () => { - createComponent({ note, }); - expect(scrollIntoViewMock).toHaveBeenCalled(); + expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); }); it('should not render edit icon when user does not have a permission', () => { @@ -143,8 +133,8 @@ describe('Design note component', () => { expect(findReplyForm().exists()).toBe(true); }); - it('hides the form on hideForm event', () => { - findReplyForm().vm.$emit('cancelForm'); + it('hides the form on cancel-form event', () => { + findReplyForm().vm.$emit('cancel-form'); return wrapper.vm.$nextTick().then(() => { expect(findReplyForm().exists()).toBe(false); @@ -152,8 +142,8 @@ describe('Design note component', () => { }); }); - it('calls a mutation on submitForm event and hides a form', () => { - findReplyForm().vm.$emit('submitForm'); + it('calls a mutation on submit-form event and hides a form', () => { + findReplyForm().vm.$emit('submit-form'); expect(mutate).toHaveBeenCalled(); return mutate() diff --git a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js index 16b34f150b8..1a80fc4e761 100644 --- a/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js +++ b/spec/frontend/design_management/components/design_notes/design_reply_form_spec.js @@ -70,7 +70,7 @@ describe('Design reply form component', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeFalsy(); + expect(wrapper.emitted('submit-form')).toBeFalsy(); }); }); @@ -80,20 +80,20 @@ describe('Design reply form component', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeFalsy(); + expect(wrapper.emitted('submit-form')).toBeFalsy(); }); }); it('emits cancelForm event on pressing escape button on textarea', () => { findTextarea().trigger('keyup.esc'); - expect(wrapper.emitted('cancelForm')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toBeTruthy(); }); it('emits cancelForm event on clicking Cancel button', () => { findCancelButton().vm.$emit('click'); - expect(wrapper.emitted('cancelForm')).toHaveLength(1); + expect(wrapper.emitted('cancel-form')).toHaveLength(1); }); }); @@ -112,7 +112,7 @@ describe('Design reply form component', () => { findSubmitButton().vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeTruthy(); + expect(wrapper.emitted('submit-form')).toBeTruthy(); }); }); @@ -122,7 +122,7 @@ describe('Design reply form component', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeTruthy(); + expect(wrapper.emitted('submit-form')).toBeTruthy(); }); }); @@ -132,7 +132,7 @@ describe('Design reply form component', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeTruthy(); + expect(wrapper.emitted('submit-form')).toBeTruthy(); }); }); @@ -147,7 +147,7 @@ describe('Design reply form component', () => { it('emits cancelForm event on Escape key if text was not changed', () => { findTextarea().trigger('keyup.esc'); - expect(wrapper.emitted('cancelForm')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toBeTruthy(); }); it('opens confirmation modal on Escape key when text has changed', () => { @@ -162,7 +162,7 @@ describe('Design reply form component', () => { it('emits cancelForm event on Cancel button click if text was not changed', () => { findCancelButton().trigger('click'); - expect(wrapper.emitted('cancelForm')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toBeTruthy(); }); it('opens confirmation modal on Cancel button click when text has changed', () => { @@ -178,7 +178,7 @@ describe('Design reply form component', () => { findTextarea().trigger('keyup.esc'); findModal().vm.$emit('ok'); - expect(wrapper.emitted('cancelForm')).toBeTruthy(); + expect(wrapper.emitted('cancel-form')).toBeTruthy(); }); }); }); diff --git a/spec/frontend/design_management/components/design_overlay_spec.js b/spec/frontend/design_management/components/design_overlay_spec.js index f243323b162..673a09320e5 100644 --- a/spec/frontend/design_management/components/design_overlay_spec.js +++ b/spec/frontend/design_management/components/design_overlay_spec.js @@ -11,11 +11,11 @@ describe('Design overlay component', () => { const mockDimensions = { width: 100, height: 100 }; - const findOverlay = () => wrapper.find('.image-diff-overlay'); const findAllNotes = () => wrapper.findAll('.js-image-badge'); const findCommentBadge = () => wrapper.find('.comment-indicator'); - const findFirstBadge = () => findAllNotes().at(0); - const findSecondBadge = () => findAllNotes().at(1); + const findBadgeAtIndex = noteIndex => findAllNotes().at(noteIndex); + const findFirstBadge = () => findBadgeAtIndex(0); + const findSecondBadge = () => findBadgeAtIndex(1); const clickAndDragBadge = (elem, fromPoint, toPoint) => { elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y }); @@ -56,9 +56,7 @@ describe('Design overlay component', () => { it('should have correct inline style', () => { createComponent(); - expect(wrapper.find('.image-diff-overlay').attributes().style).toBe( - 'width: 100px; height: 100px; top: 0px; left: 0px;', - ); + expect(wrapper.attributes().style).toBe('width: 100px; height: 100px; top: 0px; left: 0px;'); }); it('should emit `openCommentForm` when clicking on overlay', () => { @@ -69,7 +67,7 @@ describe('Design overlay component', () => { }; wrapper - .find('.image-diff-overlay-add-comment') + .find('[data-qa-selector="design_image_button"]') .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted('openCommentForm')).toEqual([ @@ -107,16 +105,43 @@ describe('Design overlay component', () => { expect(findSecondBadge().classes()).toContain('resolved'); }); - it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => { - wrapper.setData({ - activeDiscussion: { - id: notes[0].id, - source: 'discussion', - }, + describe('when no discussion is active', () => { + it('should not apply inactive class to any pins', () => { + expect( + findAllNotes(0).wrappers.every(designNote => designNote.classes('gl-bg-blue-50')), + ).toBe(false); }); + }); + + describe('when a discussion is active', () => { + it.each([notes[0].discussion.notes.nodes[1], notes[0].discussion.notes.nodes[0]])( + 'should not apply inactive class to the pin for the active discussion', + note => { + wrapper.setData({ + activeDiscussion: { + id: note.id, + source: 'discussion', + }, + }); + + return wrapper.vm.$nextTick().then(() => { + expect(findBadgeAtIndex(0).classes()).not.toContain('inactive'); + }); + }, + ); + + it('should apply inactive class to all pins besides the active one', () => { + wrapper.setData({ + activeDiscussion: { + id: notes[0].id, + source: 'discussion', + }, + }); - return wrapper.vm.$nextTick().then(() => { - expect(findSecondBadge().classes()).toContain('inactive'); + return wrapper.vm.$nextTick().then(() => { + expect(findSecondBadge().classes()).toContain('inactive'); + expect(findFirstBadge().classes()).not.toContain('inactive'); + }); }); }); }); @@ -309,7 +334,7 @@ describe('Design overlay component', () => { it.each` element | getElementFunc | event - ${'overlay'} | ${findOverlay} | ${'mouseleave'} + ${'overlay'} | ${() => wrapper} | ${'mouseleave'} ${'comment badge'} | ${findCommentBadge} | ${'mouseup'} `( 'should emit `openCommentForm` event when $event fired on $element element', diff --git a/spec/frontend/design_management/components/design_presentation_spec.js b/spec/frontend/design_management/components/design_presentation_spec.js index 7e513182589..d633d00f2ed 100644 --- a/spec/frontend/design_management/components/design_presentation_spec.js +++ b/spec/frontend/design_management/components/design_presentation_spec.js @@ -42,7 +42,7 @@ describe('Design management design presentation component', () => { wrapper.element.scrollTo = jest.fn(); } - const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment'); + const findOverlayCommentButton = () => wrapper.find('[data-qa-selector="design_image_button"]'); /** * Spy on $refs and mock given values diff --git a/spec/frontend/design_management/components/design_sidebar_spec.js b/spec/frontend/design_management/components/design_sidebar_spec.js index e098e7de867..700faa8a70f 100644 --- a/spec/frontend/design_management/components/design_sidebar_spec.js +++ b/spec/frontend/design_management/components/design_sidebar_spec.js @@ -6,6 +6,10 @@ import Participants from '~/sidebar/components/participants/participants.vue'; import DesignDiscussion from '~/design_management/components/design_notes/design_discussion.vue'; import design from '../mock_data/design'; import updateActiveDiscussionMutation from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; +import DesignTodoButton from '~/design_management/components/design_todo_button.vue'; + +const scrollIntoViewMock = jest.fn(); +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; const updateActiveDiscussionMutationVariables = { mutation: updateActiveDiscussionMutation, @@ -39,7 +43,7 @@ describe('Design management design sidebar component', () => { const findNewDiscussionDisclaimer = () => wrapper.find('[data-testid="new-discussion-disclaimer"]'); - function createComponent(props = {}) { + function createComponent(props = {}, { enableTodoButton } = {}) { wrapper = shallowMount(DesignSidebar, { propsData: { design, @@ -53,6 +57,10 @@ describe('Design management design sidebar component', () => { mutate, }, }, + stubs: { GlPopover }, + provide: { + glFeatures: { designManagementTodoButton: enableTodoButton }, + }, }); } @@ -146,22 +154,22 @@ describe('Design management design sidebar component', () => { }); it('emits correct event on discussion create note error', () => { - findFirstDiscussion().vm.$emit('createNoteError', 'payload'); + findFirstDiscussion().vm.$emit('create-note-error', 'payload'); expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]); }); it('emits correct event on discussion update note error', () => { - findFirstDiscussion().vm.$emit('updateNoteError', 'payload'); + findFirstDiscussion().vm.$emit('update-note-error', 'payload'); expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]); }); it('emits correct event on discussion resolve error', () => { - findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload'); + findFirstDiscussion().vm.$emit('resolve-discussion-error', 'payload'); expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]); }); it('changes prop correctly on opening discussion form', () => { - findFirstDiscussion().vm.$emit('openForm', 'some-id'); + findFirstDiscussion().vm.$emit('open-form', 'some-id'); return wrapper.vm.$nextTick().then(() => { expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id'); @@ -220,6 +228,10 @@ describe('Design management design sidebar component', () => { expect(findPopover().exists()).toBe(true); }); + it('scrolls to resolved threads link', () => { + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + it('dismisses a popover on the outside click', () => { wrapper.trigger('click'); return wrapper.vm.$nextTick(() => { @@ -233,4 +245,23 @@ describe('Design management design sidebar component', () => { expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); }); }); + + it('does not render To-Do button by default', () => { + createComponent(); + expect(wrapper.find(DesignTodoButton).exists()).toBe(false); + }); + + describe('when `design_management_todo_button` feature flag is enabled', () => { + beforeEach(() => { + createComponent({}, { enableTodoButton: true }); + }); + + it('renders sidebar root element with no top padding', () => { + expect(wrapper.classes()).toContain('gl-pt-0'); + }); + + it('renders To-Do button', () => { + expect(wrapper.find(DesignTodoButton).exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js new file mode 100644 index 00000000000..451c23f0fea --- /dev/null +++ b/spec/frontend/design_management/components/design_todo_button_spec.js @@ -0,0 +1,158 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import TodoButton from '~/vue_shared/components/todo_button.vue'; +import DesignTodoButton from '~/design_management/components/design_todo_button.vue'; +import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql'; +import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; +import mockDesign from '../mock_data/design'; + +const mockDesignWithPendingTodos = { + ...mockDesign, + currentUserTodos: { + nodes: [ + { + id: 'todo-id', + }, + ], + }, +}; + +const mutate = jest.fn().mockResolvedValue(); + +describe('Design management design todo button', () => { + let wrapper; + + function createComponent(props = {}, { mountFn = shallowMount } = {}) { + wrapper = mountFn(DesignTodoButton, { + propsData: { + design: mockDesign, + ...props, + }, + provide: { + projectPath: 'project-path', + issueIid: '10', + }, + mocks: { + $route: { + params: { + id: 'my-design.jpg', + }, + query: {}, + }, + $apollo: { + mutate, + }, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + jest.clearAllMocks(); + }); + + it('renders TodoButton component', () => { + expect(wrapper.find(TodoButton).exists()).toBe(true); + }); + + describe('when design has a pending todo', () => { + beforeEach(() => { + createComponent({ design: mockDesignWithPendingTodos }, { mountFn: mount }); + }); + + it('renders correct button text', () => { + expect(wrapper.text()).toBe('Mark as done'); + }); + + describe('when clicked', () => { + let dispatchEventSpy; + + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(document, 'querySelector').mockReturnValue({ + innerText: 2, + }); + + createComponent({ design: mockDesignWithPendingTodos }, { mountFn: mount }); + wrapper.trigger('click'); + return wrapper.vm.$nextTick(); + }); + + it('calls `$apollo.mutate` with the `todoMarkDone` mutation and variables containing `id`', async () => { + const todoMarkDoneMutationVariables = { + mutation: todoMarkDoneMutation, + update: expect.anything(), + variables: { + id: 'todo-id', + }, + }; + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith(todoMarkDoneMutationVariables); + }); + + it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => { + const dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchedEvent.detail).toEqual({ count: 1 }); + expect(dispatchedEvent.type).toBe('todo:toggle'); + }); + }); + }); + + describe('when design has no pending todos', () => { + beforeEach(() => { + createComponent({}, { mountFn: mount }); + }); + + it('renders correct button text', () => { + expect(wrapper.text()).toBe('Add a To-Do'); + }); + + describe('when clicked', () => { + let dispatchEventSpy; + + beforeEach(() => { + dispatchEventSpy = jest.spyOn(document, 'dispatchEvent'); + jest.spyOn(document, 'querySelector').mockReturnValue({ + innerText: 2, + }); + + createComponent({}, { mountFn: mount }); + wrapper.trigger('click'); + return wrapper.vm.$nextTick(); + }); + + it('calls `$apollo.mutate` with the `createDesignTodoMutation` mutation and variables containing `issuable_id`, `issue_id`, & `projectPath`', async () => { + const createDesignTodoMutationVariables = { + mutation: createDesignTodoMutation, + update: expect.anything(), + variables: { + atVersion: null, + filenames: ['my-design.jpg'], + designId: '1', + issueId: '1', + issueIid: '10', + projectPath: 'project-path', + }, + }; + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith(createDesignTodoMutationVariables); + }); + + it('calls dispatchDocumentEvent to update global To-Do counter correctly', () => { + const dispatchedEvent = dispatchEventSpy.mock.calls[0][0]; + + expect(dispatchEventSpy).toHaveBeenCalledTimes(1); + expect(dispatchedEvent.detail).toEqual({ count: 3 }); + expect(dispatchedEvent.type).toBe('todo:toggle'); + }); + }); + }); +}); diff --git a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap index d76b6e712fe..822df1f6472 100644 --- a/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap +++ b/spec/frontend/design_management/components/list/__snapshots__/item_spec.js.snap @@ -10,11 +10,11 @@ exports[`Design management list item component when item appears in view after i exports[`Design management list item component with notes renders item with multiple comments 1`] = ` <router-link-stub - class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" to="[object Object]" > <div - class="card-body p-0 d-flex-center overflow-hidden position-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" > <!----> @@ -23,7 +23,7 @@ exports[`Design management list item component with notes renders item with mult <img alt="test" - class="block mx-auto mw-100 mh-100 design-img" + class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" data-qa-selector="design_image" src="" /> @@ -31,13 +31,13 @@ exports[`Design management list item component with notes renders item with mult </div> <div - class="card-footer d-flex w-100" + class="card-footer gl-display-flex gl-w-full" > <div - class="d-flex flex-column str-truncated-100" + class="gl-display-flex gl-flex-direction-column str-truncated-100" > <span - class="bold str-truncated-100" + class="gl-font-weight-bold str-truncated-100" data-qa-selector="design_file_name" > test @@ -57,17 +57,17 @@ exports[`Design management list item component with notes renders item with mult </div> <div - class="ml-auto d-flex align-items-center text-secondary" + class="gl-ml-auto gl-display-flex gl-align-items-center gl-text-gray-500" > - <icon-stub - class="ml-1" + <gl-icon-stub + class="gl-ml-2" name="comments" size="16" /> <span aria-label="2 comments" - class="ml-1" + class="gl-ml-2" > 2 @@ -80,11 +80,11 @@ exports[`Design management list item component with notes renders item with mult exports[`Design management list item component with notes renders item with single comment 1`] = ` <router-link-stub - class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" + class="card gl-cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" to="[object Object]" > <div - class="card-body p-0 d-flex-center overflow-hidden position-relative" + class="card-body gl-p-0 gl-display-flex gl-align-items-center gl-justify-content-center gl-overflow-hidden gl-relative" > <!----> @@ -93,7 +93,7 @@ exports[`Design management list item component with notes renders item with sing <img alt="test" - class="block mx-auto mw-100 mh-100 design-img" + class="gl-display-block gl-mx-auto gl-max-w-full mh-100 design-img" data-qa-selector="design_image" src="" /> @@ -101,13 +101,13 @@ exports[`Design management list item component with notes renders item with sing </div> <div - class="card-footer d-flex w-100" + class="card-footer gl-display-flex gl-w-full" > <div - class="d-flex flex-column str-truncated-100" + class="gl-display-flex gl-flex-direction-column str-truncated-100" > <span - class="bold str-truncated-100" + class="gl-font-weight-bold str-truncated-100" data-qa-selector="design_file_name" > test @@ -127,17 +127,17 @@ exports[`Design management list item component with notes renders item with sing </div> <div - class="ml-auto d-flex align-items-center text-secondary" + class="gl-ml-auto gl-display-flex gl-align-items-center gl-text-gray-500" > - <icon-stub - class="ml-1" + <gl-icon-stub + class="gl-ml-2" name="comments" size="16" /> <span aria-label="1 comment" - class="ml-1" + class="gl-ml-2" > 1 diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js index d1c90bd57b0..55c6ecbc26b 100644 --- a/spec/frontend/design_management/components/list/item_spec.js +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -1,7 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import VueRouter from 'vue-router'; -import Icon from '~/vue_shared/components/icon.vue'; import Item from '~/design_management/components/list/item.vue'; const localVue = createLocalVue(); @@ -20,7 +19,7 @@ describe('Design management list item component', () => { let wrapper; const findDesignEvent = () => wrapper.find('[data-testid="designEvent"]'); - const findEventIcon = () => findDesignEvent().find(Icon); + const findEventIcon = () => findDesignEvent().find(GlIcon); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); function createComponent({ diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index d6fd09eb698..1e94e90c3b0 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` -<gl-new-dropdown-stub +<gl-dropdown-stub category="tertiary" headertext="" issueiid="" @@ -10,7 +10,7 @@ exports[`Design management design version dropdown component renders design vers text="Showing latest version" variant="default" > - <gl-new-dropdown-item-stub + <gl-dropdown-item-stub avatarurl="" iconcolor="" iconname="" @@ -22,8 +22,8 @@ exports[`Design management design version dropdown component renders design vers Version 2 (latest) - </gl-new-dropdown-item-stub> - <gl-new-dropdown-item-stub + </gl-dropdown-item-stub> + <gl-dropdown-item-stub avatarurl="" iconcolor="" iconname="" @@ -34,12 +34,12 @@ exports[`Design management design version dropdown component renders design vers Version 1 - </gl-new-dropdown-item-stub> -</gl-new-dropdown-stub> + </gl-dropdown-item-stub> +</gl-dropdown-stub> `; exports[`Design management design version dropdown component renders design version list 1`] = ` -<gl-new-dropdown-stub +<gl-dropdown-stub category="tertiary" headertext="" issueiid="" @@ -48,7 +48,7 @@ exports[`Design management design version dropdown component renders design vers text="Showing latest version" variant="default" > - <gl-new-dropdown-item-stub + <gl-dropdown-item-stub avatarurl="" iconcolor="" iconname="" @@ -60,8 +60,8 @@ exports[`Design management design version dropdown component renders design vers Version 2 (latest) - </gl-new-dropdown-item-stub> - <gl-new-dropdown-item-stub + </gl-dropdown-item-stub> + <gl-dropdown-item-stub avatarurl="" iconcolor="" iconname="" @@ -72,6 +72,6 @@ exports[`Design management design version dropdown component renders design vers Version 1 - </gl-new-dropdown-item-stub> -</gl-new-dropdown-stub> + </gl-dropdown-item-stub> +</gl-dropdown-stub> `; diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index f4206cdaeb3..4ef787ac754 100644 --- a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlNewDropdown, GlNewDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; import mockAllVersions from './mock_data/all_versions'; @@ -42,7 +42,7 @@ describe('Design management design version dropdown component', () => { wrapper.destroy(); }); - const findVersionLink = index => wrapper.findAll(GlNewDropdownItem).at(index); + const findVersionLink = index => wrapper.findAll(GlDropdownItem).at(index); it('renders design version dropdown button', () => { createComponent(); @@ -75,7 +75,7 @@ describe('Design management design version dropdown component', () => { createComponent(); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing latest version'); + expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing latest version'); }); }); @@ -83,7 +83,7 @@ describe('Design management design version dropdown component', () => { createComponent({ maxVersions: 1 }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing latest version'); + expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing latest version'); }); }); @@ -91,7 +91,7 @@ describe('Design management design version dropdown component', () => { createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlNewDropdown).attributes('text')).toBe(`Showing version #1`); + expect(wrapper.find(GlDropdown).attributes('text')).toBe(`Showing version #1`); }); }); @@ -99,7 +99,7 @@ describe('Design management design version dropdown component', () => { createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlNewDropdown).attributes('text')).toBe('Showing latest version'); + expect(wrapper.find(GlDropdown).attributes('text')).toBe('Showing latest version'); }); }); @@ -107,7 +107,7 @@ describe('Design management design version dropdown component', () => { createComponent(); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.findAll(GlNewDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + expect(wrapper.findAll(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); }); }); }); diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js index 5e2df3877a5..1c7806c292f 100644 --- a/spec/frontend/design_management/mock_data/apollo_mock.js +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -13,6 +13,9 @@ export const designListQueryResponse = { notesCount: 3, image: 'image-1', imageV432x230: 'image-1', + currentUserTodos: { + nodes: [], + }, }, { id: '2', @@ -21,6 +24,9 @@ export const designListQueryResponse = { notesCount: 2, image: 'image-2', imageV432x230: 'image-2', + currentUserTodos: { + nodes: [], + }, }, { id: '3', @@ -29,6 +35,9 @@ export const designListQueryResponse = { notesCount: 1, image: 'image-3', imageV432x230: 'image-3', + currentUserTodos: { + nodes: [], + }, }, ], }, @@ -60,6 +69,9 @@ export const reorderedDesigns = [ notesCount: 2, image: 'image-2', imageV432x230: 'image-2', + currentUserTodos: { + nodes: [], + }, }, { id: '1', @@ -68,6 +80,9 @@ export const reorderedDesigns = [ notesCount: 3, image: 'image-1', imageV432x230: 'image-1', + currentUserTodos: { + nodes: [], + }, }, { id: '3', @@ -76,6 +91,9 @@ export const reorderedDesigns = [ notesCount: 1, image: 'image-3', imageV432x230: 'image-3', + currentUserTodos: { + nodes: [], + }, }, ]; diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js index 72be33fef1d..f2a3a800969 100644 --- a/spec/frontend/design_management/mock_data/design.js +++ b/spec/frontend/design_management/mock_data/design.js @@ -1,5 +1,5 @@ export default { - id: 'design-id', + id: 'gid::/gitlab/Design/1', filename: 'test.jpg', fullPath: 'full-design-path', image: 'test.jpg', @@ -8,6 +8,7 @@ export default { name: 'test', }, issue: { + id: 'gid::/gitlab/Issue/1', title: 'My precious issue', webPath: 'full-issue-path', webUrl: 'full-issue-url', diff --git a/spec/frontend/design_management/mock_data/discussion.js b/spec/frontend/design_management/mock_data/discussion.js new file mode 100644 index 00000000000..fbf9a2fdcc1 --- /dev/null +++ b/spec/frontend/design_management/mock_data/discussion.js @@ -0,0 +1,45 @@ +export default { + id: 'discussion-id-1', + resolved: false, + resolvable: true, + notes: [ + { + id: 'note-id-1', + index: 1, + position: { + height: 100, + width: 100, + x: 10, + y: 15, + }, + author: { + name: 'John', + webUrl: 'link-to-john-profile', + }, + createdAt: '2020-05-08T07:10:45Z', + userPermissions: { + adminNote: true, + }, + resolved: false, + }, + { + id: 'note-id-3', + index: 3, + position: { + height: 50, + width: 50, + x: 25, + y: 25, + }, + author: { + name: 'Mary', + webUrl: 'link-to-mary-profile', + }, + createdAt: '2020-05-08T07:10:45Z', + userPermissions: { + adminNote: true, + }, + resolved: false, + }, + ], +}; diff --git a/spec/frontend/design_management/mock_data/notes.js b/spec/frontend/design_management/mock_data/notes.js index 80cb3944786..41cefaca05b 100644 --- a/spec/frontend/design_management/mock_data/notes.js +++ b/spec/frontend/design_management/mock_data/notes.js @@ -1,46 +1,44 @@ +import DISCUSSION_1 from './discussion'; + +const DISCUSSION_2 = { + id: 'discussion-id-2', + notes: { + nodes: [ + { + id: 'note-id-2', + index: 2, + position: { + height: 50, + width: 50, + x: 25, + y: 25, + }, + author: { + name: 'Mary', + webUrl: 'link-to-mary-profile', + }, + createdAt: '2020-05-08T07:10:45Z', + userPermissions: { + adminNote: true, + }, + resolved: true, + }, + ], + }, +}; + export default [ { - id: 'note-id-1', - index: 1, - position: { - height: 100, - width: 100, - x: 10, - y: 15, - }, - author: { - name: 'John', - webUrl: 'link-to-john-profile', - }, - createdAt: '2020-05-08T07:10:45Z', - userPermissions: { - adminNote: true, - }, + ...DISCUSSION_1.notes[0], discussion: { - id: 'discussion-id-1', + id: DISCUSSION_1.id, + notes: { + nodes: DISCUSSION_1.notes, + }, }, - resolved: false, }, { - id: 'note-id-2', - index: 2, - position: { - height: 50, - width: 50, - x: 25, - y: 25, - }, - author: { - name: 'Mary', - webUrl: 'link-to-mary-profile', - }, - createdAt: '2020-05-08T07:10:45Z', - userPermissions: { - adminNote: true, - }, - discussion: { - id: 'discussion-id-2', - }, - resolved: true, + ...DISCUSSION_2.notes.nodes[0], + discussion: DISCUSSION_2, }, ]; diff --git a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap index 3881b2d7679..b80b7fdb43e 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -111,7 +111,7 @@ exports[`Design management index page designs renders designs list and header wi > <gl-button-stub category="primary" - class="gl-mr-3 js-select-all" + class="gl-mr-4 js-select-all" icon="" size="small" variant="link" diff --git a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap index 823294efc38..c849e4d4ed6 100644 --- a/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/design/__snapshots__/index_spec.js.snap @@ -32,6 +32,8 @@ exports[`Design management design index page renders design index 1`] = ` <div class="image-notes" > + <!----> + <h2 class="gl-font-weight-bold gl-mt-0" > @@ -57,11 +59,11 @@ exports[`Design management design index page renders design index 1`] = ` <design-discussion-stub data-testid="unresolved-discussion" - designid="test" + designid="gid::/gitlab/Design/1" discussion="[object Object]" discussionwithopenform="" markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" - noteableid="design-id" + noteableid="gid::/gitlab/Design/1" /> <gl-button-stub @@ -92,7 +94,7 @@ exports[`Design management design index page renders design index 1`] = ` </p> <a - href="#" + href="https://docs.gitlab.com/ee/user/project/issues/design_management.html#resolve-design-threads" rel="noopener noreferrer" target="_blank" > @@ -105,11 +107,11 @@ exports[`Design management design index page renders design index 1`] = ` > <design-discussion-stub data-testid="resolved-discussion" - designid="test" + designid="gid::/gitlab/Design/1" discussion="[object Object]" discussionwithopenform="" markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" - noteableid="design-id" + noteableid="gid::/gitlab/Design/1" /> </gl-collapse-stub> @@ -179,6 +181,8 @@ exports[`Design management design index page with error GlAlert is rendered in c <div class="image-notes" > + <!----> + <h2 class="gl-font-weight-bold gl-mt-0" > diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 369c8667f4d..d9f7146d258 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -7,24 +7,21 @@ import DesignIndex from '~/design_management/pages/design/index.vue'; import DesignSidebar from '~/design_management/components/design_sidebar.vue'; import DesignPresentation from '~/design_management/components/design_presentation.vue'; import createImageDiffNoteMutation from '~/design_management/graphql/mutations/create_image_diff_note.mutation.graphql'; -import design from '../../mock_data/design'; -import mockResponseWithDesigns from '../../mock_data/designs'; -import mockResponseNoDesigns from '../../mock_data/no_designs'; -import mockAllVersions from '../../mock_data/all_versions'; +import updateActiveDiscussion from '~/design_management/graphql/mutations/update_active_discussion.mutation.graphql'; import { DESIGN_NOT_FOUND_ERROR, DESIGN_VERSION_NOT_EXIST_ERROR, } from '~/design_management/utils/error_messages'; -import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; +import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; import createRouter from '~/design_management/router'; import * as utils from '~/design_management/utils/design_management_utils'; import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; +import design from '../../mock_data/design'; +import mockResponseWithDesigns from '../../mock_data/designs'; +import mockResponseNoDesigns from '../../mock_data/no_designs'; +import mockAllVersions from '../../mock_data/all_versions'; jest.mock('~/flash'); -jest.mock('mousetrap', () => ({ - bind: jest.fn(), - unbind: jest.fn(), -})); const focusInput = jest.fn(); @@ -34,6 +31,12 @@ const DesignReplyForm = { focusInput, }, }; +const mockDesignNoDiscussions = { + ...design, + discussions: { + nodes: [], + }, +}; const localVue = createLocalVue(); localVue.use(VueRouter); @@ -75,7 +78,7 @@ describe('Design management design index page', () => { const findSidebar = () => wrapper.find(DesignSidebar); const findDesignPresentation = () => wrapper.find(DesignPresentation); - function createComponent(loading = false, data = {}) { + function createComponent({ loading = false } = {}, { data = {}, intialRouteOptions = {} } = {}) { const $apollo = { queries: { design: { @@ -87,6 +90,8 @@ describe('Design management design index page', () => { router = createRouter(); + router.push({ name: DESIGN_ROUTE_NAME, params: { id: design.id }, ...intialRouteOptions }); + wrapper = shallowMount(DesignIndex, { propsData: { id: '1' }, mocks: { $apollo }, @@ -126,29 +131,28 @@ describe('Design management design index page', () => { }, }; jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl); - createComponent(true); + createComponent({ loading: true }); - wrapper.vm.$router.push('/designs/test'); expect(mockEl.classList.add).toHaveBeenCalledTimes(1); expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); }); }); it('sets loading state', () => { - createComponent(true); + createComponent({ loading: true }); expect(wrapper.element).toMatchSnapshot(); }); it('renders design index', () => { - createComponent(false, { design }); + createComponent({ loading: false }, { data: { design } }); expect(wrapper.element).toMatchSnapshot(); expect(wrapper.find(GlAlert).exists()).toBe(false); }); it('passes correct props to sidebar component', () => { - createComponent(false, { design }); + createComponent({ loading: false }, { data: { design } }); expect(findSidebar().props()).toEqual({ design, @@ -158,14 +162,14 @@ describe('Design management design index page', () => { }); it('opens a new discussion form', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], + createComponent( + { loading: false }, + { + data: { + design, }, }, - }); + ); findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 }); @@ -175,15 +179,15 @@ describe('Design management design index page', () => { }); it('keeps new discussion form focused', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], + createComponent( + { loading: false }, + { + data: { + design, + annotationCoordinates, }, }, - annotationCoordinates, - }); + ); findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 }); @@ -191,18 +195,18 @@ describe('Design management design index page', () => { }); it('sends a mutation on submitting form and closes form', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], + createComponent( + { loading: false }, + { + data: { + design, + annotationCoordinates, + comment: newComment, }, }, - annotationCoordinates, - comment: newComment, - }); + ); - findDiscussionForm().vm.$emit('submitForm'); + findDiscussionForm().vm.$emit('submit-form'); expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables); return wrapper.vm @@ -216,18 +220,18 @@ describe('Design management design index page', () => { }); it('closes the form and clears the comment on canceling form', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], + createComponent( + { loading: false }, + { + data: { + design, + annotationCoordinates, + comment: newComment, }, }, - annotationCoordinates, - comment: newComment, - }); + ); - findDiscussionForm().vm.$emit('cancelForm'); + findDiscussionForm().vm.$emit('cancel-form'); expect(wrapper.vm.comment).toBe(''); @@ -238,15 +242,15 @@ describe('Design management design index page', () => { describe('with error', () => { beforeEach(() => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], + createComponent( + { loading: false }, + { + data: { + design: mockDesignNoDiscussions, + errorMessage: 'woops', }, }, - errorMessage: 'woops', - }); + ); }); it('GlAlert is rendered in correct position with correct content', () => { @@ -257,7 +261,7 @@ describe('Design management design index page', () => { describe('onDesignQueryResult', () => { describe('with no designs', () => { it('redirects to /designs', () => { - createComponent(true); + createComponent({ loading: true }); router.push = jest.fn(); wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); @@ -272,7 +276,7 @@ describe('Design management design index page', () => { describe('when no design exists for given version', () => { it('redirects to /designs', () => { - createComponent(true); + createComponent({ loading: true }); wrapper.setData({ allVersions: mockAllVersions, }); @@ -291,4 +295,24 @@ describe('Design management design index page', () => { }); }); }); + + describe('when hash present in current route', () => { + it('calls updateActiveDiscussion mutation', () => { + createComponent( + { loading: false }, + { + data: { + design, + }, + intialRouteOptions: { hash: '#note_123' }, + }, + ); + + expect(mutate).toHaveBeenCalledTimes(1); + expect(mutate).toHaveBeenCalledWith({ + mutation: updateActiveDiscussion, + variables: { id: 'gid://gitlab/DiffNote/123', source: 'url' }, + }); + }); + }); }); diff --git a/spec/frontend/design_management/pages/index_apollo_spec.js b/spec/frontend/design_management/pages/index_apollo_spec.js deleted file mode 100644 index 3ea711c2cfa..00000000000 --- a/spec/frontend/design_management/pages/index_apollo_spec.js +++ /dev/null @@ -1,162 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { createMockClient } from 'mock-apollo-client'; -import VueApollo from 'vue-apollo'; -import VueRouter from 'vue-router'; -import VueDraggable from 'vuedraggable'; -import { InMemoryCache } from 'apollo-cache-inmemory'; -import Design from '~/design_management/components/list/item.vue'; -import createRouter from '~/design_management/router'; -import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql'; -import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql'; -import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import Index from '~/design_management/pages/index.vue'; -import { - designListQueryResponse, - permissionsQueryResponse, - moveDesignMutationResponse, - reorderedDesigns, - moveDesignMutationResponseWithErrors, -} from '../mock_data/apollo_mock'; - -jest.mock('~/flash'); - -const localVue = createLocalVue(); -localVue.use(VueApollo); - -const router = createRouter(); -localVue.use(VueRouter); - -const designToMove = { - __typename: 'Design', - id: '2', - event: 'NONE', - filename: 'fox_2.jpg', - notesCount: 2, - image: 'image-2', - imageV432x230: 'image-2', -}; - -describe('Design management index page with Apollo mock', () => { - let wrapper; - let mockClient; - let apolloProvider; - let moveDesignHandler; - - async function moveDesigns(localWrapper) { - await jest.runOnlyPendingTimers(); - await localWrapper.vm.$nextTick(); - - localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns); - localWrapper.find(VueDraggable).vm.$emit('change', { - moved: { - newIndex: 0, - element: designToMove, - }, - }); - } - - const fragmentMatcher = { match: () => true }; - - const cache = new InMemoryCache({ - fragmentMatcher, - addTypename: false, - }); - - const findDesigns = () => wrapper.findAll(Design); - - function createComponent({ - moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse), - }) { - mockClient = createMockClient({ cache }); - - mockClient.setRequestHandler( - getDesignListQuery, - jest.fn().mockResolvedValue(designListQueryResponse), - ); - - mockClient.setRequestHandler( - permissionsQuery, - jest.fn().mockResolvedValue(permissionsQueryResponse), - ); - - moveDesignHandler = moveHandler; - - mockClient.setRequestHandler(moveDesignMutation, moveDesignHandler); - - apolloProvider = new VueApollo({ - defaultClient: mockClient, - }); - - wrapper = shallowMount(Index, { - localVue, - apolloProvider, - router, - stubs: { VueDraggable }, - }); - } - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - mockClient = null; - apolloProvider = null; - }); - - it('has a design with id 1 as a first one', async () => { - createComponent({}); - - await jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - expect(findDesigns()).toHaveLength(3); - expect( - findDesigns() - .at(0) - .props('id'), - ).toBe('1'); - }); - - it('calls a mutation with correct parameters and reorders designs', async () => { - createComponent({}); - - await moveDesigns(wrapper); - - expect(moveDesignHandler).toHaveBeenCalled(); - - await wrapper.vm.$nextTick(); - - expect( - findDesigns() - .at(0) - .props('id'), - ).toBe('2'); - }); - - it('displays flash if mutation had a recoverable error', async () => { - createComponent({ - moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors), - }); - - await moveDesigns(wrapper); - - await wrapper.vm.$nextTick(); - - expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem'); - }); - - it('displays flash if mutation had a non-recoverable error', async () => { - createComponent({ - moveHandler: jest.fn().mockRejectedValue('Error'), - }); - - await moveDesigns(wrapper); - - await jest.runOnlyPendingTimers(); - await wrapper.vm.$nextTick(); - - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong when reordering designs. Please try again', - ); - }); -}); diff --git a/spec/frontend/design_management/pages/index_spec.js b/spec/frontend/design_management/pages/index_spec.js index 093fa155d2e..661717d29a3 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -1,13 +1,15 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { ApolloMutation } from 'vue-apollo'; +import VueApollo, { ApolloMutation } from 'vue-apollo'; import VueDraggable from 'vuedraggable'; import VueRouter from 'vue-router'; import { GlEmptyState } from '@gitlab/ui'; +import createMockApollo from 'jest/helpers/mock_apollo_helper'; import Index from '~/design_management/pages/index.vue'; import uploadDesignQuery from '~/design_management/graphql/mutations/upload_design.mutation.graphql'; import DesignDestroyer from '~/design_management/components/design_destroyer.vue'; import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; import DeleteButton from '~/design_management/components/delete_button.vue'; +import Design from '~/design_management/components/list/item.vue'; import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; import { EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, @@ -17,6 +19,16 @@ import { deprecatedCreateFlash as createFlash } from '~/flash'; import createRouter from '~/design_management/router'; import * as utils from '~/design_management/utils/design_management_utils'; import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management/constants'; +import { + designListQueryResponse, + permissionsQueryResponse, + moveDesignMutationResponse, + reorderedDesigns, + moveDesignMutationResponseWithErrors, +} from '../mock_data/apollo_mock'; +import getDesignListQuery from '~/design_management/graphql/queries/get_design_list.query.graphql'; +import permissionsQuery from '~/design_management/graphql/queries/design_permissions.query.graphql'; +import moveDesignMutation from '~/design_management/graphql/mutations/move_design.mutation.graphql'; jest.mock('~/flash.js'); const mockPageEl = { @@ -61,9 +73,21 @@ const mockVersion = { id: 'gid://gitlab/DesignManagement::Version/1', }; +const designToMove = { + __typename: 'Design', + id: '2', + event: 'NONE', + filename: 'fox_2.jpg', + notesCount: 2, + image: 'image-2', + imageV432x230: 'image-2', +}; + describe('Design management index page', () => { let mutate; let wrapper; + let fakeApollo; + let moveDesignHandler; const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); const findSelectAllButton = () => wrapper.find('.js-select-all'); @@ -74,6 +98,20 @@ describe('Design management index page', () => { const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]'); const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]'); + const findDesigns = () => wrapper.findAll(Design); + + async function moveDesigns(localWrapper) { + await jest.runOnlyPendingTimers(); + await localWrapper.vm.$nextTick(); + + localWrapper.find(VueDraggable).vm.$emit('input', reorderedDesigns); + localWrapper.find(VueDraggable).vm.$emit('change', { + moved: { + newIndex: 0, + element: designToMove, + }, + }); + } function createComponent({ loading = false, @@ -118,8 +156,30 @@ describe('Design management index page', () => { }); } + function createComponentWithApollo({ + moveHandler = jest.fn().mockResolvedValue(moveDesignMutationResponse), + }) { + localVue.use(VueApollo); + moveDesignHandler = moveHandler; + + const requestHandlers = [ + [getDesignListQuery, jest.fn().mockResolvedValue(designListQueryResponse)], + [permissionsQuery, jest.fn().mockResolvedValue(permissionsQueryResponse)], + [moveDesignMutation, moveDesignHandler], + ]; + + fakeApollo = createMockApollo(requestHandlers); + wrapper = shallowMount(Index, { + localVue, + apolloProvider: fakeApollo, + router, + stubs: { VueDraggable }, + }); + } + afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('designs', () => { @@ -478,16 +538,15 @@ describe('Design management index page', () => { describe('on non-latest version', () => { beforeEach(() => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + }); - router.replace({ + it('does not render design checkboxes', async () => { + await router.replace({ name: DESIGNS_ROUTE_NAME, query: { version: '2', }, }); - }); - - it('does not render design checkboxes', () => { expect(findDesignCheckboxes()).toHaveLength(0); }); @@ -514,13 +573,6 @@ describe('Design management index page', () => { files: [{ name: 'image.png', type: 'image/png' }], getData: () => 'test.png', }; - - router.replace({ - name: DESIGNS_ROUTE_NAME, - query: { - version: '2', - }, - }); }); it('does not call paste event if designs wrapper is not hovered', () => { @@ -587,7 +639,69 @@ describe('Design management index page', () => { }); createComponent(true); - expect(scrollIntoViewMock).toHaveBeenCalled(); + return wrapper.vm.$nextTick().then(() => { + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); + }); + }); + + describe('with mocked Apollo client', () => { + it('has a design with id 1 as a first one', async () => { + createComponentWithApollo({}); + + await jest.runOnlyPendingTimers(); + await wrapper.vm.$nextTick(); + + expect(findDesigns()).toHaveLength(3); + expect( + findDesigns() + .at(0) + .props('id'), + ).toBe('1'); + }); + + it('calls a mutation with correct parameters and reorders designs', async () => { + createComponentWithApollo({}); + + await moveDesigns(wrapper); + + expect(moveDesignHandler).toHaveBeenCalled(); + + await wrapper.vm.$nextTick(); + + expect( + findDesigns() + .at(0) + .props('id'), + ).toBe('2'); + }); + + it('displays flash if mutation had a recoverable error', async () => { + createComponentWithApollo({ + moveHandler: jest.fn().mockResolvedValue(moveDesignMutationResponseWithErrors), + }); + + await moveDesigns(wrapper); + + await wrapper.vm.$nextTick(); + + expect(createFlash).toHaveBeenCalledWith('Houston, we have a problem'); + }); + + it('displays flash if mutation had a non-recoverable error', async () => { + createComponentWithApollo({ + moveHandler: jest.fn().mockRejectedValue('Error'), + }); + + await moveDesigns(wrapper); + + await wrapper.vm.$nextTick(); // kick off the DOM update + await jest.runOnlyPendingTimers(); // kick off the mocked GQL stuff (promises) + await wrapper.vm.$nextTick(); // kick off the DOM update for flash + + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong when reordering designs. Please try again', + ); }); }); }); diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js index 2b8c7ee959b..d4cb9f75a77 100644 --- a/spec/frontend/design_management/router_spec.js +++ b/spec/frontend/design_management/router_spec.js @@ -35,11 +35,6 @@ function factory(routeArg) { }); } -jest.mock('mousetrap', () => ({ - bind: jest.fn(), - unbind: jest.fn(), -})); - describe('Design management router', () => { afterEach(() => { window.location.hash = ''; diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js index e8a5cf3949d..6c859e8c3e8 100644 --- a/spec/frontend/design_management/utils/cache_update_spec.js +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -1,14 +1,12 @@ import { InMemoryCache } from 'apollo-cache-inmemory'; import { updateStoreAfterDesignsDelete, - updateStoreAfterAddDiscussionComment, updateStoreAfterAddImageDiffNote, updateStoreAfterUploadDesign, updateStoreAfterUpdateImageDiffNote, } from '~/design_management/utils/cache_update'; import { designDeletionError, - ADD_DISCUSSION_COMMENT_ERROR, ADD_IMAGE_DIFF_NOTE_ERROR, UPDATE_IMAGE_DIFF_NOTE_ERROR, } from '~/design_management/utils/error_messages'; @@ -28,12 +26,11 @@ describe('Design Management cache update', () => { describe('error handling', () => { it.each` - fnName | subject | errorMessage | extraArgs - ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} - ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]} - ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} - ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} - ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} + fnName | subject | errorMessage | extraArgs + ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} + ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} + ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} + ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { expect(createFlash).not.toHaveBeenCalled(); expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); diff --git a/spec/frontend/design_management/utils/design_management_utils_spec.js b/spec/frontend/design_management/utils/design_management_utils_spec.js index e6d836b9157..7e857d08d25 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -6,6 +6,7 @@ import { updateImageDiffNoteOptimisticResponse, isValidDesignFile, extractDesign, + extractDesignNoteId, } from '~/design_management/utils/design_management_utils'; import mockResponseNoDesigns from '../mock_data/no_designs'; import mockResponseWithDesigns from '../mock_data/designs'; @@ -171,3 +172,19 @@ describe('extractDesign', () => { }); }); }); + +describe('extractDesignNoteId', () => { + it.each` + hash | expectedNoteId + ${'#note_0'} | ${'0'} + ${'#note_1'} | ${'1'} + ${'#note_23'} | ${'23'} + ${'#note_456'} | ${'456'} + ${'note_1'} | ${null} + ${'#note_'} | ${null} + ${'#note_asd'} | ${null} + ${'#note_1asd'} | ${null} + `('returns $expectedNoteId when hash is $hash', ({ hash, expectedNoteId }) => { + expect(extractDesignNoteId(hash)).toBe(expectedNoteId); + }); +}); diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap deleted file mode 100644 index 62a0f675cff..00000000000 --- a/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap +++ /dev/null @@ -1,42 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design note pin component should match the snapshot of note when repositioning 1`] = ` -<button - aria-label="Comment form position" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator" - style="left: 10px; top: 10px; cursor: move;" - type="button" -> - <gl-icon-stub - name="image-comment-dark" - size="24" - /> -</button> -`; - -exports[`Design note pin component should match the snapshot of note with index 1`] = ` -<button - aria-label="Comment '1' position" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 js-image-badge badge badge-pill" - style="left: 10px; top: 10px;" - type="button" -> - - 1 - -</button> -`; - -exports[`Design note pin component should match the snapshot of note without index 1`] = ` -<button - aria-label="Comment form position" - class="design-pin gl-absolute gl-display-flex gl-align-items-center gl-justify-content-center gl-p-0 btn-transparent comment-indicator" - style="left: 10px; top: 10px;" - type="button" -> - <gl-icon-stub - name="image-comment-dark" - size="24" - /> -</button> -`; diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap deleted file mode 100644 index 189962c5b2e..00000000000 --- a/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap +++ /dev/null @@ -1,104 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management design presentation component currentCommentForm is equal to current annotation position when isAnnotating is true 1`] = ` -<div - class="h-100 w-100 p-3 overflow-auto position-relative" -> - <div - class="h-100 w-100 d-flex align-items-center position-relative" - > - <design-image-stub - image="test.jpg" - name="test" - scale="1" - /> - - <design-overlay-stub - currentcommentform="[object Object]" - dimensions="[object Object]" - notes="" - position="[object Object]" - /> - </div> -</div> -`; - -exports[`Design management design presentation component currentCommentForm is null when isAnnotating is false 1`] = ` -<div - class="h-100 w-100 p-3 overflow-auto position-relative" -> - <div - class="h-100 w-100 d-flex align-items-center position-relative" - > - <design-image-stub - image="test.jpg" - name="test" - scale="1" - /> - - <design-overlay-stub - dimensions="[object Object]" - notes="" - position="[object Object]" - /> - </div> -</div> -`; - -exports[`Design management design presentation component currentCommentForm is null when isAnnotating is true but annotation position is falsey 1`] = ` -<div - class="h-100 w-100 p-3 overflow-auto position-relative" -> - <div - class="h-100 w-100 d-flex align-items-center position-relative" - > - <design-image-stub - image="test.jpg" - name="test" - scale="1" - /> - - <design-overlay-stub - dimensions="[object Object]" - notes="" - position="[object Object]" - /> - </div> -</div> -`; - -exports[`Design management design presentation component renders empty state when no image provided 1`] = ` -<div - class="h-100 w-100 p-3 overflow-auto position-relative" -> - <div - class="h-100 w-100 d-flex align-items-center position-relative" - > - <!----> - - <!----> - </div> -</div> -`; - -exports[`Design management design presentation component renders image and overlay when image provided 1`] = ` -<div - class="h-100 w-100 p-3 overflow-auto position-relative" -> - <div - class="h-100 w-100 d-flex align-items-center position-relative" - > - <design-image-stub - image="test.jpg" - name="test" - scale="1" - /> - - <design-overlay-stub - dimensions="[object Object]" - notes="" - position="[object Object]" - /> - </div> -</div> -`; diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap deleted file mode 100644 index cb4575cbd11..00000000000 --- a/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap +++ /dev/null @@ -1,115 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management design scaler component minus and reset buttons are disabled when scale === 1 1`] = ` -<div - class="design-scaler btn-group" - role="group" -> - <button - class="btn" - disabled="disabled" - > - <span - class="d-flex-center gl-icon s16" - > - - – - - </span> - </button> - - <button - class="btn" - disabled="disabled" - > - <gl-icon-stub - name="redo" - size="16" - /> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="plus" - size="16" - /> - </button> -</div> -`; - -exports[`Design management design scaler component minus and reset buttons are enabled when scale > 1 1`] = ` -<div - class="design-scaler btn-group" - role="group" -> - <button - class="btn" - > - <span - class="d-flex-center gl-icon s16" - > - - – - - </span> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="redo" - size="16" - /> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="plus" - size="16" - /> - </button> -</div> -`; - -exports[`Design management design scaler component plus button is disabled when scale === 2 1`] = ` -<div - class="design-scaler btn-group" - role="group" -> - <button - class="btn" - > - <span - class="d-flex-center gl-icon s16" - > - - – - - </span> - </button> - - <button - class="btn" - > - <gl-icon-stub - name="redo" - size="16" - /> - </button> - - <button - class="btn" - disabled="disabled" - > - <gl-icon-stub - name="plus" - size="16" - /> - </button> -</div> -`; diff --git a/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap deleted file mode 100644 index acaa62b11eb..00000000000 --- a/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap +++ /dev/null @@ -1,68 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management large image component renders image 1`] = ` -<div - class="m-auto js-design-image" -> - <!----> - - <img - alt="test" - class="mh-100 img-fluid" - src="test.jpg" - /> -</div> -`; - -exports[`Design management large image component renders loading state 1`] = ` -<div - class="m-auto js-design-image" - isloading="true" -> - <!----> - - <img - alt="" - class="mh-100 img-fluid" - src="" - /> -</div> -`; - -exports[`Design management large image component renders media broken icon on error 1`] = ` -<gl-icon-stub - class="text-secondary-100" - name="media-broken" - size="48" -/> -`; - -exports[`Design management large image component sets correct classes and styles if imageStyle is set 1`] = ` -<div - class="m-auto js-design-image" -> - <!----> - - <img - alt="test" - class="mh-100" - src="test.jpg" - style="width: 100px; height: 100px;" - /> -</div> -`; - -exports[`Design management large image component zoom sets image style when zoomed 1`] = ` -<div - class="m-auto js-design-image" -> - <!----> - - <img - alt="test" - class="mh-100" - src="test.jpg" - style="width: 200px; height: 200px;" - /> -</div> -`; diff --git a/spec/frontend/design_management_legacy/components/delete_button_spec.js b/spec/frontend/design_management_legacy/components/delete_button_spec.js deleted file mode 100644 index 73b4908d06a..00000000000 --- a/spec/frontend/design_management_legacy/components/delete_button_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; -import BatchDeleteButton from '~/design_management_legacy/components/delete_button.vue'; - -describe('Batch delete button component', () => { - let wrapper; - - const findButton = () => wrapper.find(GlDeprecatedButton); - const findModal = () => wrapper.find(GlModal); - - function createComponent(isDeleting = false) { - wrapper = shallowMount(BatchDeleteButton, { - propsData: { - isDeleting, - }, - directives: { - GlModalDirective, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders non-disabled button by default', () => { - createComponent(); - - expect(findButton().exists()).toBe(true); - expect(findButton().attributes('disabled')).toBeFalsy(); - }); - - it('renders disabled button when design is deleting', () => { - createComponent(true); - expect(findButton().attributes('disabled')).toBeTruthy(); - }); - - it('emits `deleteSelectedDesigns` event on modal ok click', () => { - createComponent(); - findButton().vm.$emit('click'); - return wrapper.vm - .$nextTick() - .then(() => { - findModal().vm.$emit('ok'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.emitted().deleteSelectedDesigns).toBeTruthy(); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_note_pin_spec.js b/spec/frontend/design_management_legacy/components/design_note_pin_spec.js deleted file mode 100644 index 3077928cf86..00000000000 --- a/spec/frontend/design_management_legacy/components/design_note_pin_spec.js +++ /dev/null @@ -1,49 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DesignNotePin from '~/design_management_legacy/components/design_note_pin.vue'; - -describe('Design note pin component', () => { - let wrapper; - - function createComponent(propsData = {}) { - wrapper = shallowMount(DesignNotePin, { - propsData: { - position: { - left: '10px', - top: '10px', - }, - ...propsData, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('should match the snapshot of note without index', () => { - createComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('should match the snapshot of note with index', () => { - createComponent({ label: 1 }); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('should match the snapshot of note when repositioning', () => { - createComponent({ repositioning: true }); - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('pinStyle', () => { - it('sets cursor to `move` when repositioning = true', () => { - createComponent({ repositioning: true }); - expect(wrapper.vm.pinStyle.cursor).toBe('move'); - }); - - it('does not set cursor when repositioning = false', () => { - createComponent(); - expect(wrapper.vm.pinStyle.cursor).toBe(undefined); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap deleted file mode 100644 index b55bacb6fc5..00000000000 --- a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap +++ /dev/null @@ -1,67 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design note component should match the snapshot 1`] = ` -<timeline-entry-item-stub - class="design-note note-form" - id="note_123" -> - <user-avatar-link-stub - imgalt="" - imgcssclasses="" - imgsize="40" - imgsrc="" - linkhref="" - tooltipplacement="top" - tooltiptext="" - username="" - /> - - <div - class="d-flex justify-content-between" - > - <div> - <a - class="js-user-link" - data-user-id="author-id" - > - <span - class="note-header-author-name bold" - > - - </span> - - <!----> - - <span - class="note-headline-light" - > - @ - </span> - </a> - - <span - class="note-headline-light note-headline-meta" - > - <span - class="system-note-message" - /> - - <!----> - </span> - </div> - - <div - class="gl-display-flex" - > - - <!----> - </div> - </div> - - <div - class="note-text js-note-text md" - data-qa-selector="note_content" - /> - -</timeline-entry-item-stub> -`; diff --git a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap b/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap deleted file mode 100644 index e01c79e3520..00000000000 --- a/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_reply_form_spec.js.snap +++ /dev/null @@ -1,15 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design reply form component renders button text as "Comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> - <!----> - Comment -</button>" -`; - -exports[`Design reply form component renders button text as "Save comment" when creating a comment 1`] = ` -"<button data-track-event=\\"click_button\\" data-qa-selector=\\"save_comment_button\\" type=\\"submit\\" disabled=\\"disabled\\" class=\\"btn btn-success btn-md disabled\\"> - <!----> - Save comment -</button>" -`; diff --git a/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js deleted file mode 100644 index d20be97f470..00000000000 --- a/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js +++ /dev/null @@ -1,318 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; -import notes from '../../mock_data/notes'; -import DesignDiscussion from '~/design_management_legacy/components/design_notes/design_discussion.vue'; -import DesignNote from '~/design_management_legacy/components/design_notes/design_note.vue'; -import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue'; -import createNoteMutation from '~/design_management_legacy/graphql/mutations/create_note.mutation.graphql'; -import toggleResolveDiscussionMutation from '~/design_management_legacy/graphql/mutations/toggle_resolve_discussion.mutation.graphql'; -import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; -import ToggleRepliesWidget from '~/design_management_legacy/components/design_notes/toggle_replies_widget.vue'; - -const discussion = { - id: '0', - resolved: false, - resolvable: true, - notes, -}; - -describe('Design discussions component', () => { - let wrapper; - - const findDesignNotes = () => wrapper.findAll(DesignNote); - const findReplyPlaceholder = () => wrapper.find(ReplyPlaceholder); - const findReplyForm = () => wrapper.find(DesignReplyForm); - const findRepliesWidget = () => wrapper.find(ToggleRepliesWidget); - const findResolveButton = () => wrapper.find('[data-testid="resolve-button"]'); - const findResolveIcon = () => wrapper.find('[data-testid="resolve-icon"]'); - const findResolvedMessage = () => wrapper.find('[data-testid="resolved-message"]'); - const findResolveLoadingIcon = () => wrapper.find(GlLoadingIcon); - const findResolveCheckbox = () => wrapper.find('[data-testid="resolve-checkbox"]'); - - const mutationVariables = { - mutation: createNoteMutation, - update: expect.anything(), - variables: { - input: { - noteableId: 'noteable-id', - body: 'test', - discussionId: '0', - }, - }, - }; - const mutate = jest.fn(() => Promise.resolve()); - const $apollo = { - mutate, - }; - - function createComponent(props = {}, data = {}) { - wrapper = mount(DesignDiscussion, { - propsData: { - resolvedDiscussionsExpanded: true, - discussion, - noteableId: 'noteable-id', - designId: 'design-id', - discussionIndex: 1, - discussionWithOpenForm: '', - ...props, - }, - data() { - return { - ...data, - }; - }, - mocks: { - $apollo, - $route: { - hash: '#note_1', - }, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when discussion is not resolvable', () => { - beforeEach(() => { - createComponent({ - discussion: { - ...discussion, - resolvable: false, - }, - }); - }); - - it('does not render an icon to resolve a thread', () => { - expect(findResolveIcon().exists()).toBe(false); - }); - - it('does not render a checkbox in reply form', () => { - findReplyPlaceholder().vm.$emit('onMouseDown'); - - return wrapper.vm.$nextTick().then(() => { - expect(findResolveCheckbox().exists()).toBe(false); - }); - }); - }); - - describe('when discussion is unresolved', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders correct amount of discussion notes', () => { - expect(findDesignNotes()).toHaveLength(2); - expect(findDesignNotes().wrappers.every(w => w.isVisible())).toBe(true); - }); - - it('renders reply placeholder', () => { - expect(findReplyPlaceholder().isVisible()).toBe(true); - }); - - it('does not render toggle replies widget', () => { - expect(findRepliesWidget().exists()).toBe(false); - }); - - it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle'); - }); - - it('renders a checkbox with Resolve thread text in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); - wrapper.setProps({ discussionWithOpenForm: discussion.id }); - - return wrapper.vm.$nextTick().then(() => { - expect(findResolveCheckbox().text()).toBe('Resolve thread'); - }); - }); - - it('does not render resolved message', () => { - expect(findResolvedMessage().exists()).toBe(false); - }); - }); - - describe('when discussion is resolved', () => { - beforeEach(() => { - createComponent({ - discussion: { - ...discussion, - resolved: true, - resolvedBy: notes[0].author, - resolvedAt: '2020-05-08T07:10:45Z', - }, - }); - }); - - it('shows only the first note', () => { - expect( - findDesignNotes() - .at(0) - .isVisible(), - ).toBe(true); - expect( - findDesignNotes() - .at(1) - .isVisible(), - ).toBe(false); - }); - - it('renders resolved message', () => { - expect(findResolvedMessage().exists()).toBe(true); - }); - - it('does not show renders reply placeholder', () => { - expect(findReplyPlaceholder().isVisible()).toBe(false); - }); - - it('renders toggle replies widget with correct props', () => { - expect(findRepliesWidget().exists()).toBe(true); - expect(findRepliesWidget().props()).toEqual({ - collapsed: true, - replies: notes.slice(1), - }); - }); - - it('renders a correct icon to resolve a thread', () => { - expect(findResolveIcon().props('name')).toBe('check-circle-filled'); - }); - - describe('when replies are expanded', () => { - beforeEach(() => { - findRepliesWidget().vm.$emit('toggle'); - return wrapper.vm.$nextTick(); - }); - - it('renders replies widget with collapsed prop equal to false', () => { - expect(findRepliesWidget().props('collapsed')).toBe(false); - }); - - it('renders the second note', () => { - expect( - findDesignNotes() - .at(1) - .isVisible(), - ).toBe(true); - }); - - it('renders a reply placeholder', () => { - expect(findReplyPlaceholder().isVisible()).toBe(true); - }); - - it('renders a checkbox with Unresolve thread text in reply form', () => { - findReplyPlaceholder().vm.$emit('onClick'); - wrapper.setProps({ discussionWithOpenForm: discussion.id }); - - return wrapper.vm.$nextTick().then(() => { - expect(findResolveCheckbox().text()).toBe('Unresolve thread'); - }); - }); - }); - }); - - it('hides reply placeholder and opens form on placeholder click', () => { - createComponent(); - findReplyPlaceholder().vm.$emit('onClick'); - wrapper.setProps({ discussionWithOpenForm: discussion.id }); - - return wrapper.vm.$nextTick().then(() => { - expect(findReplyPlaceholder().exists()).toBe(false); - expect(findReplyForm().exists()).toBe(true); - }); - }); - - it('calls mutation on submitting form and closes the form', () => { - createComponent( - { discussionWithOpenForm: discussion.id }, - { discussionComment: 'test', isFormRendered: true }, - ); - - findReplyForm().vm.$emit('submitForm'); - expect(mutate).toHaveBeenCalledWith(mutationVariables); - - return mutate() - .then(() => { - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findReplyForm().exists()).toBe(false); - }); - }); - - it('clears the discussion comment on closing comment form', () => { - createComponent( - { discussionWithOpenForm: discussion.id }, - { discussionComment: 'test', isFormRendered: true }, - ); - - return wrapper.vm - .$nextTick() - .then(() => { - findReplyForm().vm.$emit('cancelForm'); - - expect(wrapper.vm.discussionComment).toBe(''); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findReplyForm().exists()).toBe(false); - }); - }); - - it('applies correct class to design notes when discussion is highlighted', () => { - createComponent( - {}, - { - activeDiscussion: { - id: notes[0].id, - source: 'pin', - }, - }, - ); - - expect(wrapper.findAll(DesignNote).wrappers.every(note => note.classes('gl-bg-blue-50'))).toBe( - true, - ); - }); - - it('calls toggleResolveDiscussion mutation on resolve thread button click', () => { - createComponent(); - findResolveButton().trigger('click'); - expect(mutate).toHaveBeenCalledWith({ - mutation: toggleResolveDiscussionMutation, - variables: { - id: discussion.id, - resolve: true, - }, - }); - return wrapper.vm.$nextTick(() => { - expect(findResolveLoadingIcon().exists()).toBe(true); - }); - }); - - it('calls toggleResolveDiscussion mutation after adding a note if checkbox was checked', () => { - createComponent( - { discussionWithOpenForm: discussion.id }, - { discussionComment: 'test', isFormRendered: true }, - ); - findResolveButton().trigger('click'); - findReplyForm().vm.$emit('submitForm'); - - return mutate().then(() => { - expect(mutate).toHaveBeenCalledWith({ - mutation: toggleResolveDiscussionMutation, - variables: { - id: discussion.id, - resolve: true, - }, - }); - }); - }); - - it('emits openForm event on opening the form', () => { - createComponent(); - findReplyPlaceholder().vm.$emit('onClick'); - - expect(wrapper.emitted('openForm')).toBeTruthy(); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js deleted file mode 100644 index aa187cd1388..00000000000 --- a/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js +++ /dev/null @@ -1,170 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { ApolloMutation } from 'vue-apollo'; -import DesignNote from '~/design_management_legacy/components/design_notes/design_note.vue'; -import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue'; - -const scrollIntoViewMock = jest.fn(); -const note = { - id: 'gid://gitlab/DiffNote/123', - author: { - id: 'author-id', - }, - body: 'test', - userPermissions: { - adminNote: false, - }, -}; -HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; - -const $route = { - hash: '#note_123', -}; - -const mutate = jest.fn().mockResolvedValue({ data: { updateNote: {} } }); - -describe('Design note component', () => { - let wrapper; - - const findUserAvatar = () => wrapper.find(UserAvatarLink); - const findUserLink = () => wrapper.find('.js-user-link'); - const findReplyForm = () => wrapper.find(DesignReplyForm); - const findEditButton = () => wrapper.find('.js-note-edit'); - const findNoteContent = () => wrapper.find('.js-note-text'); - - function createComponent(props = {}, data = { isEditing: false }) { - wrapper = shallowMount(DesignNote, { - propsData: { - note: {}, - ...props, - }, - data() { - return { - ...data, - }; - }, - mocks: { - $route, - $apollo: { - mutate, - }, - }, - stubs: { - ApolloMutation, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('should match the snapshot', () => { - createComponent({ - note, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('should render an author', () => { - createComponent({ - note, - }); - - expect(findUserAvatar().exists()).toBe(true); - expect(findUserLink().exists()).toBe(true); - }); - - it('should render a time ago tooltip if note has createdAt property', () => { - createComponent({ - note: { - ...note, - createdAt: '2019-07-26T15:02:20Z', - }, - }); - - expect(wrapper.find(TimeAgoTooltip).exists()).toBe(true); - }); - - it('should trigger a scrollIntoView method', () => { - createComponent({ - note, - }); - - expect(scrollIntoViewMock).toHaveBeenCalled(); - }); - - it('should not render edit icon when user does not have a permission', () => { - createComponent({ - note, - }); - - expect(findEditButton().exists()).toBe(false); - }); - - describe('when user has a permission to edit note', () => { - it('should open an edit form on edit button click', () => { - createComponent({ - note: { - ...note, - userPermissions: { - adminNote: true, - }, - }, - }); - - findEditButton().trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(findReplyForm().exists()).toBe(true); - expect(findNoteContent().exists()).toBe(false); - }); - }); - - describe('when edit form is rendered', () => { - beforeEach(() => { - createComponent( - { - note: { - ...note, - userPermissions: { - adminNote: true, - }, - }, - }, - { isEditing: true }, - ); - }); - - it('should not render note content and should render reply form', () => { - expect(findNoteContent().exists()).toBe(false); - expect(findReplyForm().exists()).toBe(true); - }); - - it('hides the form on hideForm event', () => { - findReplyForm().vm.$emit('cancelForm'); - - return wrapper.vm.$nextTick().then(() => { - expect(findReplyForm().exists()).toBe(false); - expect(findNoteContent().exists()).toBe(true); - }); - }); - - it('calls a mutation on submitForm event and hides a form', () => { - findReplyForm().vm.$emit('submitForm'); - expect(mutate).toHaveBeenCalled(); - - return mutate() - .then(() => { - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findReplyForm().exists()).toBe(false); - expect(findNoteContent().exists()).toBe(true); - }); - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js deleted file mode 100644 index 088a71b64af..00000000000 --- a/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js +++ /dev/null @@ -1,184 +0,0 @@ -import { mount } from '@vue/test-utils'; -import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue'; - -const showModal = jest.fn(); - -const GlModal = { - template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', - methods: { - show: showModal, - }, -}; - -describe('Design reply form component', () => { - let wrapper; - - const findTextarea = () => wrapper.find('textarea'); - const findSubmitButton = () => wrapper.find({ ref: 'submitButton' }); - const findCancelButton = () => wrapper.find({ ref: 'cancelButton' }); - const findModal = () => wrapper.find({ ref: 'cancelCommentModal' }); - - function createComponent(props = {}, mountOptions = {}) { - wrapper = mount(DesignReplyForm, { - propsData: { - value: '', - isSaving: false, - ...props, - }, - stubs: { GlModal }, - ...mountOptions, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('textarea has focus after component mount', () => { - // We need to attach to document, so that `document.activeElement` is properly set in jsdom - createComponent({}, { attachToDocument: true }); - - expect(findTextarea().element).toEqual(document.activeElement); - }); - - it('renders button text as "Comment" when creating a comment', () => { - createComponent(); - - expect(findSubmitButton().html()).toMatchSnapshot(); - }); - - it('renders button text as "Save comment" when creating a comment', () => { - createComponent({ isNewComment: false }); - - expect(findSubmitButton().html()).toMatchSnapshot(); - }); - - describe('when form has no text', () => { - beforeEach(() => { - createComponent({ - value: '', - }); - }); - - it('submit button is disabled', () => { - expect(findSubmitButton().attributes().disabled).toBeTruthy(); - }); - - it('does not emit submitForm event on textarea ctrl+enter keydown', () => { - findTextarea().trigger('keydown.enter', { - ctrlKey: true, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeFalsy(); - }); - }); - - it('does not emit submitForm event on textarea meta+enter keydown', () => { - findTextarea().trigger('keydown.enter', { - metaKey: true, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeFalsy(); - }); - }); - - it('emits cancelForm event on pressing escape button on textarea', () => { - findTextarea().trigger('keyup.esc'); - - expect(wrapper.emitted('cancelForm')).toBeTruthy(); - }); - - it('emits cancelForm event on clicking Cancel button', () => { - findCancelButton().vm.$emit('click'); - - expect(wrapper.emitted('cancelForm')).toHaveLength(1); - }); - }); - - describe('when form has text', () => { - beforeEach(() => { - createComponent({ - value: 'test', - }); - }); - - it('submit button is enabled', () => { - expect(findSubmitButton().attributes().disabled).toBeFalsy(); - }); - - it('emits submitForm event on Comment button click', () => { - findSubmitButton().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeTruthy(); - }); - }); - - it('emits submitForm event on textarea ctrl+enter keydown', () => { - findTextarea().trigger('keydown.enter', { - ctrlKey: true, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeTruthy(); - }); - }); - - it('emits submitForm event on textarea meta+enter keydown', () => { - findTextarea().trigger('keydown.enter', { - metaKey: true, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('submitForm')).toBeTruthy(); - }); - }); - - it('emits input event on changing textarea content', () => { - findTextarea().setValue('test2'); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('input')).toBeTruthy(); - }); - }); - - it('emits cancelForm event on Escape key if text was not changed', () => { - findTextarea().trigger('keyup.esc'); - - expect(wrapper.emitted('cancelForm')).toBeTruthy(); - }); - - it('opens confirmation modal on Escape key when text has changed', () => { - wrapper.setProps({ value: 'test2' }); - - return wrapper.vm.$nextTick().then(() => { - findTextarea().trigger('keyup.esc'); - expect(showModal).toHaveBeenCalled(); - }); - }); - - it('emits cancelForm event on Cancel button click if text was not changed', () => { - findCancelButton().trigger('click'); - - expect(wrapper.emitted('cancelForm')).toBeTruthy(); - }); - - it('opens confirmation modal on Cancel button click when text has changed', () => { - wrapper.setProps({ value: 'test2' }); - - return wrapper.vm.$nextTick().then(() => { - findCancelButton().trigger('click'); - expect(showModal).toHaveBeenCalled(); - }); - }); - - it('emits cancelForm event on modal Ok button click', () => { - findTextarea().trigger('keyup.esc'); - findModal().vm.$emit('ok'); - - expect(wrapper.emitted('cancelForm')).toBeTruthy(); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js b/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js deleted file mode 100644 index acc7cbbca52..00000000000 --- a/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js +++ /dev/null @@ -1,98 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlIcon, GlButton, GlLink } from '@gitlab/ui'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; -import ToggleRepliesWidget from '~/design_management_legacy/components/design_notes/toggle_replies_widget.vue'; -import notes from '../../mock_data/notes'; - -describe('Toggle replies widget component', () => { - let wrapper; - - const findToggleWrapper = () => wrapper.find('[data-testid="toggle-comments-wrapper"]'); - const findIcon = () => wrapper.find(GlIcon); - const findButton = () => wrapper.find(GlButton); - const findAuthorLink = () => wrapper.find(GlLink); - const findTimeAgo = () => wrapper.find(TimeAgoTooltip); - - function createComponent(props = {}) { - wrapper = shallowMount(ToggleRepliesWidget, { - propsData: { - collapsed: true, - replies: notes, - ...props, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when replies are collapsed', () => { - beforeEach(() => { - createComponent(); - }); - - it('should not have expanded class', () => { - expect(findToggleWrapper().classes()).not.toContain('expanded'); - }); - - it('should render chevron-right icon', () => { - expect(findIcon().props('name')).toBe('chevron-right'); - }); - - it('should have replies length on button', () => { - expect(findButton().text()).toBe('2 replies'); - }); - - it('should render a link to the last reply author', () => { - expect(findAuthorLink().exists()).toBe(true); - expect(findAuthorLink().text()).toBe(notes[1].author.name); - expect(findAuthorLink().attributes('href')).toBe(notes[1].author.webUrl); - }); - - it('should render correct time ago tooltip', () => { - expect(findTimeAgo().exists()).toBe(true); - expect(findTimeAgo().props('time')).toBe(notes[1].createdAt); - }); - }); - - describe('when replies are expanded', () => { - beforeEach(() => { - createComponent({ collapsed: false }); - }); - - it('should have expanded class', () => { - expect(findToggleWrapper().classes()).toContain('expanded'); - }); - - it('should render chevron-down icon', () => { - expect(findIcon().props('name')).toBe('chevron-down'); - }); - - it('should have Collapse replies text on button', () => { - expect(findButton().text()).toBe('Collapse replies'); - }); - - it('should not have a link to the last reply author', () => { - expect(findAuthorLink().exists()).toBe(false); - }); - - it('should not render time ago tooltip', () => { - expect(findTimeAgo().exists()).toBe(false); - }); - }); - - it('should emit toggle event on icon click', () => { - createComponent(); - findIcon().vm.$emit('click', new MouseEvent('click')); - - expect(wrapper.emitted('toggle')).toHaveLength(1); - }); - - it('should emit toggle event on button click', () => { - createComponent(); - findButton().vm.$emit('click', new MouseEvent('click')); - - expect(wrapper.emitted('toggle')).toHaveLength(1); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_overlay_spec.js b/spec/frontend/design_management_legacy/components/design_overlay_spec.js deleted file mode 100644 index c014f3479f4..00000000000 --- a/spec/frontend/design_management_legacy/components/design_overlay_spec.js +++ /dev/null @@ -1,410 +0,0 @@ -import { mount } from '@vue/test-utils'; -import DesignOverlay from '~/design_management_legacy/components/design_overlay.vue'; -import updateActiveDiscussion from '~/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql'; -import notes from '../mock_data/notes'; -import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_legacy/constants'; - -const mutate = jest.fn(() => Promise.resolve()); - -describe('Design overlay component', () => { - let wrapper; - - const mockDimensions = { width: 100, height: 100 }; - - const findOverlay = () => wrapper.find('.image-diff-overlay'); - const findAllNotes = () => wrapper.findAll('.js-image-badge'); - const findCommentBadge = () => wrapper.find('.comment-indicator'); - const findFirstBadge = () => findAllNotes().at(0); - const findSecondBadge = () => findAllNotes().at(1); - - const clickAndDragBadge = (elem, fromPoint, toPoint) => { - elem.trigger('mousedown', { clientX: fromPoint.x, clientY: fromPoint.y }); - return wrapper.vm.$nextTick().then(() => { - elem.trigger('mousemove', { clientX: toPoint.x, clientY: toPoint.y }); - return wrapper.vm.$nextTick(); - }); - }; - - function createComponent(props = {}, data = {}) { - wrapper = mount(DesignOverlay, { - propsData: { - dimensions: mockDimensions, - position: { - top: '0', - left: '0', - }, - resolvedDiscussionsExpanded: false, - ...props, - }, - data() { - return { - activeDiscussion: { - id: null, - source: null, - }, - ...data, - }; - }, - mocks: { - $apollo: { - mutate, - }, - }, - }); - } - - it('should have correct inline style', () => { - createComponent(); - - expect(wrapper.find('.image-diff-overlay').attributes().style).toBe( - 'width: 100px; height: 100px; top: 0px; left: 0px;', - ); - }); - - it('should emit `openCommentForm` when clicking on overlay', () => { - createComponent(); - const newCoordinates = { - x: 10, - y: 10, - }; - - wrapper - .find('.image-diff-overlay-add-comment') - .trigger('mouseup', { offsetX: newCoordinates.x, offsetY: newCoordinates.y }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('openCommentForm')).toEqual([ - [{ x: newCoordinates.x, y: newCoordinates.y }], - ]); - }); - }); - - describe('with notes', () => { - it('should render only the first note', () => { - createComponent({ - notes, - }); - expect(findAllNotes()).toHaveLength(1); - }); - - describe('with resolved discussions toggle expanded', () => { - beforeEach(() => { - createComponent({ - notes, - resolvedDiscussionsExpanded: true, - }); - }); - - it('should render all notes', () => { - expect(findAllNotes()).toHaveLength(notes.length); - }); - - it('should have set the correct position for each note badge', () => { - expect(findFirstBadge().attributes().style).toBe('left: 10px; top: 15px;'); - expect(findSecondBadge().attributes().style).toBe('left: 50px; top: 50px;'); - }); - - it('should apply resolved class to the resolved note pin', () => { - expect(findSecondBadge().classes()).toContain('resolved'); - }); - - it('when there is an active discussion, should apply inactive class to all pins besides the active one', () => { - wrapper.setData({ - activeDiscussion: { - id: notes[0].id, - source: 'discussion', - }, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(findSecondBadge().classes()).toContain('inactive'); - }); - }); - }); - - it('should recalculate badges positions on window resize', () => { - createComponent({ - notes, - dimensions: { - width: 400, - height: 400, - }, - }); - - expect(findFirstBadge().attributes().style).toBe('left: 40px; top: 60px;'); - - wrapper.setProps({ - dimensions: { - width: 200, - height: 200, - }, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 30px;'); - }); - }); - - it('should call an update active discussion mutation when clicking a note without moving it', () => { - const note = notes[0]; - const { position } = note; - const mutationVariables = { - mutation: updateActiveDiscussion, - variables: { - id: note.id, - source: ACTIVE_DISCUSSION_SOURCE_TYPES.pin, - }, - }; - - findFirstBadge().trigger('mousedown', { clientX: position.x, clientY: position.y }); - - return wrapper.vm.$nextTick().then(() => { - findFirstBadge().trigger('mouseup', { clientX: position.x, clientY: position.y }); - expect(mutate).toHaveBeenCalledWith(mutationVariables); - }); - }); - }); - - describe('when moving notes', () => { - it('should update badge style when note is being moved', () => { - createComponent({ - notes, - }); - - const { position } = notes[0]; - - return clickAndDragBadge( - findFirstBadge(), - { x: position.x, y: position.y }, - { x: 20, y: 20 }, - ).then(() => { - expect(findFirstBadge().attributes().style).toBe('left: 20px; top: 20px; cursor: move;'); - }); - }); - - it('should emit `moveNote` event when note-moving action ends', () => { - createComponent({ notes }); - const note = notes[0]; - const { position } = note; - const newCoordinates = { x: 20, y: 20 }; - - wrapper.setData({ - movingNoteNewPosition: { - ...position, - ...newCoordinates, - }, - movingNoteStartPosition: { - noteId: notes[0].id, - discussionId: notes[0].discussion.id, - ...position, - }, - }); - - const badge = findFirstBadge(); - return clickAndDragBadge(badge, { x: position.x, y: position.y }, newCoordinates) - .then(() => { - badge.trigger('mouseup'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.emitted('moveNote')).toEqual([ - [ - { - noteId: notes[0].id, - discussionId: notes[0].discussion.id, - coordinates: newCoordinates, - }, - ], - ]); - }); - }); - - describe('without [adminNote] permission', () => { - const mockNoteNotAuthorised = { - ...notes[0], - userPermissions: { - adminNote: false, - }, - }; - - const mockNoteCoordinates = { - x: mockNoteNotAuthorised.position.x, - y: mockNoteNotAuthorised.position.y, - }; - - it('should be unable to move a note', () => { - createComponent({ - dimensions: mockDimensions, - notes: [mockNoteNotAuthorised], - }); - - const badge = findAllNotes().at(0); - return clickAndDragBadge(badge, { ...mockNoteCoordinates }, { x: 20, y: 20 }).then(() => { - // note position should not change after a click-and-drag attempt - expect(findFirstBadge().attributes().style).toContain( - `left: ${mockNoteCoordinates.x}px; top: ${mockNoteCoordinates.y}px;`, - ); - }); - }); - }); - }); - - describe('with a new form', () => { - it('should render a new comment badge', () => { - createComponent({ - currentCommentForm: { - ...notes[0].position, - }, - }); - - expect(findCommentBadge().exists()).toBe(true); - expect(findCommentBadge().attributes().style).toBe('left: 10px; top: 15px;'); - }); - - describe('when moving the comment badge', () => { - it('should update badge style to reflect new position', () => { - const { position } = notes[0]; - - createComponent({ - currentCommentForm: { - ...position, - }, - }); - - return clickAndDragBadge( - findCommentBadge(), - { x: position.x, y: position.y }, - { x: 20, y: 20 }, - ).then(() => { - expect(findCommentBadge().attributes().style).toBe( - 'left: 20px; top: 20px; cursor: move;', - ); - }); - }); - - it('should update badge style when note-moving action ends', () => { - const { position } = notes[0]; - createComponent({ - currentCommentForm: { - ...position, - }, - }); - - const commentBadge = findCommentBadge(); - const toPoint = { x: 20, y: 20 }; - - return clickAndDragBadge(commentBadge, { x: position.x, y: position.y }, toPoint) - .then(() => { - commentBadge.trigger('mouseup'); - // simulates the currentCommentForm being updated in index.vue component, and - // propagated back down to this prop - wrapper.setProps({ - currentCommentForm: { height: position.height, width: position.width, ...toPoint }, - }); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(commentBadge.attributes().style).toBe('left: 20px; top: 20px;'); - }); - }); - - it.each` - element | getElementFunc | event - ${'overlay'} | ${findOverlay} | ${'mouseleave'} - ${'comment badge'} | ${findCommentBadge} | ${'mouseup'} - `( - 'should emit `openCommentForm` event when $event fired on $element element', - ({ getElementFunc, event }) => { - createComponent({ - notes, - currentCommentForm: { - ...notes[0].position, - }, - }); - - const newCoordinates = { x: 20, y: 20 }; - wrapper.setData({ - movingNoteStartPosition: { - ...notes[0].position, - }, - movingNoteNewPosition: { - ...notes[0].position, - ...newCoordinates, - }, - }); - - getElementFunc().trigger(event); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('openCommentForm')).toEqual([[newCoordinates]]); - }); - }, - ); - }); - }); - - describe('getMovingNotePositionDelta', () => { - it('should calculate delta correctly from state', () => { - createComponent(); - - wrapper.setData({ - movingNoteStartPosition: { - clientX: 10, - clientY: 20, - }, - }); - - const mockMouseEvent = { - clientX: 30, - clientY: 10, - }; - - expect(wrapper.vm.getMovingNotePositionDelta(mockMouseEvent)).toEqual({ - deltaX: 20, - deltaY: -10, - }); - }); - }); - - describe('isPositionInOverlay', () => { - createComponent({ dimensions: mockDimensions }); - - it.each` - test | coordinates | expectedResult - ${'within overlay bounds'} | ${{ x: 50, y: 50 }} | ${true} - ${'outside overlay bounds'} | ${{ x: 101, y: 101 }} | ${false} - `('returns [$expectedResult] when position is $test', ({ coordinates, expectedResult }) => { - const position = { ...mockDimensions, ...coordinates }; - - expect(wrapper.vm.isPositionInOverlay(position)).toBe(expectedResult); - }); - }); - - describe('getNoteRelativePosition', () => { - it('calculates position correctly', () => { - createComponent({ dimensions: mockDimensions }); - const position = { x: 50, y: 50, width: 200, height: 200 }; - - expect(wrapper.vm.getNoteRelativePosition(position)).toEqual({ left: 25, top: 25 }); - }); - }); - - describe('canMoveNote', () => { - it.each` - adminNotePermission | canMoveNoteResult - ${true} | ${true} - ${false} | ${false} - ${undefined} | ${false} - `( - 'returns [$canMoveNoteResult] when [adminNote permission] is [$adminNotePermission]', - ({ adminNotePermission, canMoveNoteResult }) => { - createComponent(); - - const note = { - userPermissions: { - adminNote: adminNotePermission, - }, - }; - expect(wrapper.vm.canMoveNote(note)).toBe(canMoveNoteResult); - }, - ); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_presentation_spec.js b/spec/frontend/design_management_legacy/components/design_presentation_spec.js deleted file mode 100644 index ceff86b0549..00000000000 --- a/spec/frontend/design_management_legacy/components/design_presentation_spec.js +++ /dev/null @@ -1,553 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DesignPresentation from '~/design_management_legacy/components/design_presentation.vue'; -import DesignOverlay from '~/design_management_legacy/components/design_overlay.vue'; - -const mockOverlayData = { - overlayDimensions: { - width: 100, - height: 100, - }, - overlayPosition: { - top: '0', - left: '0', - }, -}; - -describe('Design management design presentation component', () => { - let wrapper; - - function createComponent( - { - image, - imageName, - discussions = [], - isAnnotating = false, - resolvedDiscussionsExpanded = false, - } = {}, - data = {}, - stubs = {}, - ) { - wrapper = shallowMount(DesignPresentation, { - propsData: { - image, - imageName, - discussions, - isAnnotating, - resolvedDiscussionsExpanded, - }, - stubs, - }); - - wrapper.setData(data); - wrapper.element.scrollTo = jest.fn(); - } - - const findOverlayCommentButton = () => wrapper.find('.image-diff-overlay-add-comment'); - - /** - * Spy on $refs and mock given values - * @param {Object} viewportDimensions {width, height} - * @param {Object} childDimensions {width, height} - * @param {Float} scrollTopPerc 0 < x < 1 - * @param {Float} scrollLeftPerc 0 < x < 1 - */ - function mockRefDimensions( - ref, - viewportDimensions, - childDimensions, - scrollTopPerc, - scrollLeftPerc, - ) { - jest.spyOn(ref, 'scrollWidth', 'get').mockReturnValue(childDimensions.width); - jest.spyOn(ref, 'scrollHeight', 'get').mockReturnValue(childDimensions.height); - jest.spyOn(ref, 'offsetWidth', 'get').mockReturnValue(viewportDimensions.width); - jest.spyOn(ref, 'offsetHeight', 'get').mockReturnValue(viewportDimensions.height); - jest - .spyOn(ref, 'scrollLeft', 'get') - .mockReturnValue((childDimensions.width - viewportDimensions.width) * scrollLeftPerc); - jest - .spyOn(ref, 'scrollTop', 'get') - .mockReturnValue((childDimensions.height - viewportDimensions.height) * scrollTopPerc); - } - - function clickDragExplore(startCoords, endCoords, { useTouchEvents, mouseup } = {}) { - const event = useTouchEvents - ? { - mousedown: 'touchstart', - mousemove: 'touchmove', - mouseup: 'touchend', - } - : { - mousedown: 'mousedown', - mousemove: 'mousemove', - mouseup: 'mouseup', - }; - - const addCommentOverlay = findOverlayCommentButton(); - - // triggering mouse events on this element best simulates - // reality, as it is the lowest-level node that needs to - // respond to mouse events - addCommentOverlay.trigger(event.mousedown, { - clientX: startCoords.clientX, - clientY: startCoords.clientY, - }); - return wrapper.vm - .$nextTick() - .then(() => { - addCommentOverlay.trigger(event.mousemove, { - clientX: endCoords.clientX, - clientY: endCoords.clientY, - }); - - return wrapper.vm.$nextTick(); - }) - .then(() => { - if (mouseup) { - addCommentOverlay.trigger(event.mouseup); - return wrapper.vm.$nextTick(); - } - - return undefined; - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders image and overlay when image provided', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - ); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders empty state when no image provided', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('openCommentForm event emits correct data', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - ); - - wrapper.vm.openCommentForm({ x: 1, y: 1 }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('openCommentForm')).toEqual([ - [{ ...mockOverlayData.overlayDimensions, x: 1, y: 1 }], - ]); - }); - }); - - describe('currentCommentForm', () => { - it('is null when isAnnotating is false', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - ); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.currentCommentForm).toBeNull(); - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('is null when isAnnotating is true but annotation position is falsey', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - isAnnotating: true, - }, - mockOverlayData, - ); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.currentCommentForm).toBeNull(); - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('is equal to current annotation position when isAnnotating is true', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - isAnnotating: true, - }, - { - ...mockOverlayData, - currentAnnotationPosition: { - x: 1, - y: 1, - width: 100, - height: 100, - }, - }, - ); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.currentCommentForm).toEqual({ - x: 1, - y: 1, - width: 100, - height: 100, - }); - expect(wrapper.element).toMatchSnapshot(); - }); - }); - }); - - describe('setOverlayPosition', () => { - beforeEach(() => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - ); - }); - - afterEach(() => { - jest.clearAllMocks(); - }); - - it('sets overlay position correctly when overlay is smaller than viewport', () => { - jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); - jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); - - wrapper.vm.setOverlayPosition(); - expect(wrapper.vm.overlayPosition).toEqual({ - left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`, - top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`, - }); - }); - - it('sets overlay position correctly when overlay width is larger than viewports', () => { - jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(50); - jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(200); - - wrapper.vm.setOverlayPosition(); - expect(wrapper.vm.overlayPosition).toEqual({ - left: '0', - top: `calc(50% - ${mockOverlayData.overlayDimensions.height / 2}px)`, - }); - }); - - it('sets overlay position correctly when overlay height is larger than viewports', () => { - jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetWidth', 'get').mockReturnValue(200); - jest.spyOn(wrapper.vm.$refs.presentationViewport, 'offsetHeight', 'get').mockReturnValue(50); - - wrapper.vm.setOverlayPosition(); - expect(wrapper.vm.overlayPosition).toEqual({ - left: `calc(50% - ${mockOverlayData.overlayDimensions.width / 2}px)`, - top: '0', - }); - }); - }); - - describe('getViewportCenter', () => { - beforeEach(() => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - ); - }); - - it('calculate center correctly with no scroll', () => { - mockRefDimensions( - wrapper.vm.$refs.presentationViewport, - { width: 10, height: 10 }, - { width: 20, height: 20 }, - 0, - 0, - ); - - expect(wrapper.vm.getViewportCenter()).toEqual({ - x: 5, - y: 5, - }); - }); - - it('calculate center correctly with some scroll', () => { - mockRefDimensions( - wrapper.vm.$refs.presentationViewport, - { width: 10, height: 10 }, - { width: 20, height: 20 }, - 0.5, - 0.5, - ); - - expect(wrapper.vm.getViewportCenter()).toEqual({ - x: 10, - y: 10, - }); - }); - - it('Returns default case if no overflow (scrollWidth==offsetWidth, etc.)', () => { - mockRefDimensions( - wrapper.vm.$refs.presentationViewport, - { width: 20, height: 20 }, - { width: 20, height: 20 }, - 0.5, - 0.5, - ); - - expect(wrapper.vm.getViewportCenter()).toEqual({ - x: 10, - y: 10, - }); - }); - }); - - describe('scaleZoomFocalPoint', () => { - it('scales focal point correctly when zooming in', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - { - ...mockOverlayData, - zoomFocalPoint: { - x: 5, - y: 5, - width: 50, - height: 50, - }, - }, - ); - - wrapper.vm.scaleZoomFocalPoint(); - expect(wrapper.vm.zoomFocalPoint).toEqual({ - x: 10, - y: 10, - width: 100, - height: 100, - }); - }); - - it('scales focal point correctly when zooming out', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - { - ...mockOverlayData, - zoomFocalPoint: { - x: 10, - y: 10, - width: 200, - height: 200, - }, - }, - ); - - wrapper.vm.scaleZoomFocalPoint(); - expect(wrapper.vm.zoomFocalPoint).toEqual({ - x: 5, - y: 5, - width: 100, - height: 100, - }); - }); - }); - - describe('onImageResize', () => { - it('sets zoom focal point on initial load', () => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - ); - - wrapper.setMethods({ - shiftZoomFocalPoint: jest.fn(), - scaleZoomFocalPoint: jest.fn(), - scrollToFocalPoint: jest.fn(), - }); - - wrapper.vm.onImageResize({ width: 10, height: 10 }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.shiftZoomFocalPoint).toHaveBeenCalled(); - expect(wrapper.vm.initialLoad).toBe(false); - }); - }); - - it('calls scaleZoomFocalPoint and scrollToFocalPoint after initial load', () => { - wrapper.vm.onImageResize({ width: 10, height: 10 }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.scaleZoomFocalPoint).toHaveBeenCalled(); - expect(wrapper.vm.scrollToFocalPoint).toHaveBeenCalled(); - }); - }); - }); - - describe('onPresentationMousedown', () => { - it.each` - scenario | width | height - ${'width overflows'} | ${101} | ${100} - ${'height overflows'} | ${100} | ${101} - ${'width and height overflows'} | ${200} | ${200} - `('sets lastDragPosition when design $scenario', ({ width, height }) => { - createComponent(); - mockRefDimensions( - wrapper.vm.$refs.presentationViewport, - { width: 100, height: 100 }, - { width, height }, - ); - - const newLastDragPosition = { x: 2, y: 2 }; - wrapper.vm.onPresentationMousedown({ - clientX: newLastDragPosition.x, - clientY: newLastDragPosition.y, - }); - - expect(wrapper.vm.lastDragPosition).toStrictEqual(newLastDragPosition); - }); - - it('does not set lastDragPosition if design does not overflow', () => { - const lastDragPosition = { x: 1, y: 1 }; - - createComponent({}, { lastDragPosition }); - mockRefDimensions( - wrapper.vm.$refs.presentationViewport, - { width: 100, height: 100 }, - { width: 50, height: 50 }, - ); - - wrapper.vm.onPresentationMousedown({ clientX: 2, clientY: 2 }); - - // check lastDragPosition is unchanged - expect(wrapper.vm.lastDragPosition).toStrictEqual(lastDragPosition); - }); - }); - - describe('getAnnotationPositon', () => { - it.each` - coordinates | overlayDimensions | position - ${{ x: 100, y: 100 }} | ${{ width: 50, height: 50 }} | ${{ x: 100, y: 100, width: 50, height: 50 }} - ${{ x: 100.2, y: 100.5 }} | ${{ width: 50.6, height: 50.0 }} | ${{ x: 100, y: 101, width: 51, height: 50 }} - `('returns correct annotation position', ({ coordinates, overlayDimensions, position }) => { - createComponent(undefined, { - overlayDimensions: { - width: overlayDimensions.width, - height: overlayDimensions.height, - }, - }); - - expect(wrapper.vm.getAnnotationPositon(coordinates)).toStrictEqual(position); - }); - }); - - describe('when design is overflowing', () => { - beforeEach(() => { - createComponent( - { - image: 'test.jpg', - imageName: 'test', - }, - mockOverlayData, - { - 'design-overlay': DesignOverlay, - }, - ); - - // mock a design that overflows - mockRefDimensions( - wrapper.vm.$refs.presentationViewport, - { width: 10, height: 10 }, - { width: 20, height: 20 }, - 0, - 0, - ); - }); - - it('opens a comment form if design was not dragged', () => { - const addCommentOverlay = findOverlayCommentButton(); - const startCoords = { - clientX: 1, - clientY: 1, - }; - - addCommentOverlay.trigger('mousedown', { - clientX: startCoords.clientX, - clientY: startCoords.clientY, - }); - - return wrapper.vm - .$nextTick() - .then(() => { - addCommentOverlay.trigger('mouseup'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.emitted('openCommentForm')).toBeDefined(); - }); - }); - - describe('when clicking and dragging', () => { - it.each` - description | useTouchEvents - ${'with touch events'} | ${true} - ${'without touch events'} | ${false} - `('calls scrollTo with correct arguments $description', ({ useTouchEvents }) => { - return clickDragExplore( - { clientX: 0, clientY: 0 }, - { clientX: 10, clientY: 10 }, - { useTouchEvents }, - ).then(() => { - expect(wrapper.element.scrollTo).toHaveBeenCalledTimes(1); - expect(wrapper.element.scrollTo).toHaveBeenCalledWith(-10, -10); - }); - }); - - it('does not open a comment form when drag position exceeds buffer', () => { - return clickDragExplore( - { clientX: 0, clientY: 0 }, - { clientX: 10, clientY: 10 }, - { mouseup: true }, - ).then(() => { - expect(wrapper.emitted('openCommentForm')).toBeFalsy(); - }); - }); - - it('opens a comment form when drag position is within buffer', () => { - return clickDragExplore( - { clientX: 0, clientY: 0 }, - { clientX: 1, clientY: 0 }, - { mouseup: true }, - ).then(() => { - expect(wrapper.emitted('openCommentForm')).toBeDefined(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_scaler_spec.js b/spec/frontend/design_management_legacy/components/design_scaler_spec.js deleted file mode 100644 index 30ef5ab159b..00000000000 --- a/spec/frontend/design_management_legacy/components/design_scaler_spec.js +++ /dev/null @@ -1,67 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DesignScaler from '~/design_management_legacy/components/design_scaler.vue'; - -describe('Design management design scaler component', () => { - let wrapper; - - function createComponent(propsData, data = {}) { - wrapper = shallowMount(DesignScaler, { - propsData, - }); - wrapper.setData(data); - } - - afterEach(() => { - wrapper.destroy(); - }); - - const getButton = type => { - const buttonTypeOrder = ['minus', 'reset', 'plus']; - const buttons = wrapper.findAll('button'); - return buttons.at(buttonTypeOrder.indexOf(type)); - }; - - it('emits @scale event when "plus" button clicked', () => { - createComponent(); - - getButton('plus').trigger('click'); - expect(wrapper.emitted('scale')).toEqual([[1.2]]); - }); - - it('emits @scale event when "reset" button clicked (scale > 1)', () => { - createComponent({}, { scale: 1.6 }); - return wrapper.vm.$nextTick().then(() => { - getButton('reset').trigger('click'); - expect(wrapper.emitted('scale')).toEqual([[1]]); - }); - }); - - it('emits @scale event when "minus" button clicked (scale > 1)', () => { - createComponent({}, { scale: 1.6 }); - - return wrapper.vm.$nextTick().then(() => { - getButton('minus').trigger('click'); - expect(wrapper.emitted('scale')).toEqual([[1.4]]); - }); - }); - - it('minus and reset buttons are disabled when scale === 1', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('minus and reset buttons are enabled when scale > 1', () => { - createComponent({}, { scale: 1.2 }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('plus button is disabled when scale === 2', () => { - createComponent({}, { scale: 2 }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/design_sidebar_spec.js b/spec/frontend/design_management_legacy/components/design_sidebar_spec.js deleted file mode 100644 index fc0f618c359..00000000000 --- a/spec/frontend/design_management_legacy/components/design_sidebar_spec.js +++ /dev/null @@ -1,236 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlCollapse, GlPopover } from '@gitlab/ui'; -import Cookies from 'js-cookie'; -import DesignSidebar from '~/design_management_legacy/components/design_sidebar.vue'; -import Participants from '~/sidebar/components/participants/participants.vue'; -import DesignDiscussion from '~/design_management_legacy/components/design_notes/design_discussion.vue'; -import design from '../mock_data/design'; -import updateActiveDiscussionMutation from '~/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql'; - -const updateActiveDiscussionMutationVariables = { - mutation: updateActiveDiscussionMutation, - variables: { - id: design.discussions.nodes[0].notes.nodes[0].id, - source: 'discussion', - }, -}; - -const $route = { - params: { - id: '1', - }, -}; - -const cookieKey = 'hide_design_resolved_comments_popover'; - -const mutate = jest.fn().mockResolvedValue(); - -describe('Design management design sidebar component', () => { - let wrapper; - - const findDiscussions = () => wrapper.findAll(DesignDiscussion); - const findFirstDiscussion = () => findDiscussions().at(0); - const findUnresolvedDiscussions = () => wrapper.findAll('[data-testid="unresolved-discussion"]'); - const findResolvedDiscussions = () => wrapper.findAll('[data-testid="resolved-discussion"]'); - const findParticipants = () => wrapper.find(Participants); - const findCollapsible = () => wrapper.find(GlCollapse); - const findToggleResolvedCommentsButton = () => wrapper.find('[data-testid="resolved-comments"]'); - const findPopover = () => wrapper.find(GlPopover); - const findNewDiscussionDisclaimer = () => - wrapper.find('[data-testid="new-discussion-disclaimer"]'); - - function createComponent(props = {}) { - wrapper = shallowMount(DesignSidebar, { - propsData: { - design, - resolvedDiscussionsExpanded: false, - markdownPreviewPath: '', - ...props, - }, - mocks: { - $route, - $apollo: { - mutate, - }, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders participants', () => { - createComponent(); - - expect(findParticipants().exists()).toBe(true); - }); - - it('passes the correct amount of participants to the Participants component', () => { - createComponent(); - - expect(findParticipants().props('participants')).toHaveLength(1); - }); - - describe('when has no discussions', () => { - beforeEach(() => { - createComponent({ - design: { - ...design, - discussions: { - nodes: [], - }, - }, - }); - }); - - it('does not render discussions', () => { - expect(findDiscussions().exists()).toBe(false); - }); - - it('renders a message about possibility to create a new discussion', () => { - expect(findNewDiscussionDisclaimer().exists()).toBe(true); - }); - }); - - describe('when has discussions', () => { - beforeEach(() => { - Cookies.set(cookieKey, true); - createComponent(); - }); - - it('renders correct amount of unresolved discussions', () => { - expect(findUnresolvedDiscussions()).toHaveLength(1); - }); - - it('renders correct amount of resolved discussions', () => { - expect(findResolvedDiscussions()).toHaveLength(1); - }); - - it('has resolved comments collapsible collapsed', () => { - expect(findCollapsible().attributes('visible')).toBeUndefined(); - }); - - it('emits toggleResolveComments event on resolve comments button click', () => { - findToggleResolvedCommentsButton().vm.$emit('click'); - expect(wrapper.emitted('toggleResolvedComments')).toHaveLength(1); - }); - - it('opens a collapsible when resolvedDiscussionsExpanded prop changes to true', () => { - expect(findCollapsible().attributes('visible')).toBeUndefined(); - wrapper.setProps({ - resolvedDiscussionsExpanded: true, - }); - return wrapper.vm.$nextTick().then(() => { - expect(findCollapsible().attributes('visible')).toBe('true'); - }); - }); - - it('does not popover about resolved comments', () => { - expect(findPopover().exists()).toBe(false); - }); - - it('sends a mutation to set an active discussion when clicking on a discussion', () => { - findFirstDiscussion().trigger('click'); - - expect(mutate).toHaveBeenCalledWith(updateActiveDiscussionMutationVariables); - }); - - it('sends a mutation to reset an active discussion when clicking outside of discussion', () => { - wrapper.trigger('click'); - - expect(mutate).toHaveBeenCalledWith({ - ...updateActiveDiscussionMutationVariables, - variables: { id: undefined, source: 'discussion' }, - }); - }); - - it('emits correct event on discussion create note error', () => { - findFirstDiscussion().vm.$emit('createNoteError', 'payload'); - expect(wrapper.emitted('onDesignDiscussionError')).toEqual([['payload']]); - }); - - it('emits correct event on discussion update note error', () => { - findFirstDiscussion().vm.$emit('updateNoteError', 'payload'); - expect(wrapper.emitted('updateNoteError')).toEqual([['payload']]); - }); - - it('emits correct event on discussion resolve error', () => { - findFirstDiscussion().vm.$emit('resolveDiscussionError', 'payload'); - expect(wrapper.emitted('resolveDiscussionError')).toEqual([['payload']]); - }); - - it('changes prop correctly on opening discussion form', () => { - findFirstDiscussion().vm.$emit('openForm', 'some-id'); - - return wrapper.vm.$nextTick().then(() => { - expect(findFirstDiscussion().props('discussionWithOpenForm')).toBe('some-id'); - }); - }); - }); - - describe('when all discussions are resolved', () => { - beforeEach(() => { - createComponent({ - design: { - ...design, - discussions: { - nodes: [ - { - id: 'discussion-id', - replyId: 'discussion-reply-id', - resolved: true, - notes: { - nodes: [ - { - id: 'note-id', - body: '123', - author: { - name: 'Administrator', - username: 'root', - webUrl: 'link-to-author', - avatarUrl: 'link-to-avatar', - }, - }, - ], - }, - }, - ], - }, - }, - }); - }); - - it('renders a message about possibility to create a new discussion', () => { - expect(findNewDiscussionDisclaimer().exists()).toBe(true); - }); - - it('does not render unresolved discussions', () => { - expect(findUnresolvedDiscussions()).toHaveLength(0); - }); - }); - - describe('when showing resolved discussions for the first time', () => { - beforeEach(() => { - Cookies.set(cookieKey, false); - createComponent(); - }); - - it('renders a popover if we show resolved comments collapsible for the first time', () => { - expect(findPopover().exists()).toBe(true); - }); - - it('dismisses a popover on the outside click', () => { - wrapper.trigger('click'); - return wrapper.vm.$nextTick(() => { - expect(findPopover().exists()).toBe(false); - }); - }); - - it(`sets a ${cookieKey} cookie on clicking outside the popover`, () => { - jest.spyOn(Cookies, 'set'); - wrapper.trigger('click'); - expect(Cookies.set).toHaveBeenCalledWith(cookieKey, 'true', { expires: 365 * 10 }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/image_spec.js b/spec/frontend/design_management_legacy/components/image_spec.js deleted file mode 100644 index 265c91abb4e..00000000000 --- a/spec/frontend/design_management_legacy/components/image_spec.js +++ /dev/null @@ -1,133 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; -import DesignImage from '~/design_management_legacy/components/image.vue'; - -describe('Design management large image component', () => { - let wrapper; - - function createComponent(propsData, data = {}) { - wrapper = shallowMount(DesignImage, { - propsData, - }); - wrapper.setData(data); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders loading state', () => { - createComponent({ - isLoading: true, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders image', () => { - createComponent({ - isLoading: false, - image: 'test.jpg', - name: 'test', - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('sets correct classes and styles if imageStyle is set', () => { - createComponent( - { - isLoading: false, - image: 'test.jpg', - name: 'test', - }, - { - imageStyle: { - width: '100px', - height: '100px', - }, - }, - ); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders media broken icon on error', () => { - createComponent({ - isLoading: false, - image: 'test.jpg', - name: 'test', - }); - - const image = wrapper.find('img'); - image.trigger('error'); - return wrapper.vm.$nextTick().then(() => { - expect(image.isVisible()).toBe(false); - expect(wrapper.find(GlIcon).element).toMatchSnapshot(); - }); - }); - - describe('zoom', () => { - const baseImageWidth = 100; - const baseImageHeight = 100; - - beforeEach(() => { - createComponent( - { - isLoading: false, - image: 'test.jpg', - name: 'test', - }, - { - imageStyle: { - width: `${baseImageWidth}px`, - height: `${baseImageHeight}px`, - }, - baseImageSize: { - width: baseImageWidth, - height: baseImageHeight, - }, - }, - ); - - jest.spyOn(wrapper.vm.$refs.contentImg, 'offsetWidth', 'get').mockReturnValue(baseImageWidth); - jest - .spyOn(wrapper.vm.$refs.contentImg, 'offsetHeight', 'get') - .mockReturnValue(baseImageHeight); - }); - - it('emits @resize event on zoom', () => { - const zoomAmount = 2; - wrapper.vm.zoom(zoomAmount); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('resize')).toEqual([ - [{ width: baseImageWidth * zoomAmount, height: baseImageHeight * zoomAmount }], - ]); - }); - }); - - it('emits @resize event with base image size when scale=1', () => { - wrapper.vm.zoom(1); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('resize')).toEqual([ - [{ width: baseImageWidth, height: baseImageHeight }], - ]); - }); - }); - - it('sets image style when zoomed', () => { - const zoomAmount = 2; - wrapper.vm.zoom(zoomAmount); - expect(wrapper.vm.imageStyle).toEqual({ - width: `${baseImageWidth * zoomAmount}px`, - height: `${baseImageHeight * zoomAmount}px`, - }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap deleted file mode 100644 index 168b9424006..00000000000 --- a/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap +++ /dev/null @@ -1,149 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management list item component when item appears in view after image is loaded renders media broken icon when image onerror triggered 1`] = ` -<gl-icon-stub - class="text-secondary" - name="media-broken" - size="32" -/> -`; - -exports[`Design management list item component with notes renders item with multiple comments 1`] = ` -<router-link-stub - class="card cursor-pointer text-plain js-design-list-item design-list-item" - to="[object Object]" -> - <div - class="card-body p-0 d-flex-center overflow-hidden position-relative" - > - <!----> - - <gl-intersection-observer-stub> - <!----> - - <img - alt="test" - class="block mx-auto mw-100 mh-100 design-img" - data-qa-selector="design_image" - src="" - /> - </gl-intersection-observer-stub> - </div> - - <div - class="card-footer d-flex w-100" - > - <div - class="d-flex flex-column str-truncated-100" - > - <span - class="bold str-truncated-100" - data-qa-selector="design_file_name" - > - test - </span> - - <span - class="str-truncated-100" - > - - Updated - <timeago-stub - cssclass="" - time="01-01-2019" - tooltipplacement="bottom" - /> - </span> - </div> - - <div - class="ml-auto d-flex align-items-center text-secondary" - > - <icon-stub - class="ml-1" - name="comments" - size="16" - /> - - <span - aria-label="2 comments" - class="ml-1" - > - - 2 - - </span> - </div> - </div> -</router-link-stub> -`; - -exports[`Design management list item component with notes renders item with single comment 1`] = ` -<router-link-stub - class="card cursor-pointer text-plain js-design-list-item design-list-item" - to="[object Object]" -> - <div - class="card-body p-0 d-flex-center overflow-hidden position-relative" - > - <!----> - - <gl-intersection-observer-stub> - <!----> - - <img - alt="test" - class="block mx-auto mw-100 mh-100 design-img" - data-qa-selector="design_image" - src="" - /> - </gl-intersection-observer-stub> - </div> - - <div - class="card-footer d-flex w-100" - > - <div - class="d-flex flex-column str-truncated-100" - > - <span - class="bold str-truncated-100" - data-qa-selector="design_file_name" - > - test - </span> - - <span - class="str-truncated-100" - > - - Updated - <timeago-stub - cssclass="" - time="01-01-2019" - tooltipplacement="bottom" - /> - </span> - </div> - - <div - class="ml-auto d-flex align-items-center text-secondary" - > - <icon-stub - class="ml-1" - name="comments" - size="16" - /> - - <span - aria-label="1 comment" - class="ml-1" - > - - 1 - - </span> - </div> - </div> -</router-link-stub> -`; diff --git a/spec/frontend/design_management_legacy/components/list/item_spec.js b/spec/frontend/design_management_legacy/components/list/item_spec.js deleted file mode 100644 index e9bb0fc3f29..00000000000 --- a/spec/frontend/design_management_legacy/components/list/item_spec.js +++ /dev/null @@ -1,169 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; -import VueRouter from 'vue-router'; -import Icon from '~/vue_shared/components/icon.vue'; -import Item from '~/design_management_legacy/components/list/item.vue'; - -const localVue = createLocalVue(); -localVue.use(VueRouter); -const router = new VueRouter(); - -// Referenced from: doc/api/graphql/reference/gitlab_schema.graphql:DesignVersionEvent -const DESIGN_VERSION_EVENT = { - CREATION: 'CREATION', - DELETION: 'DELETION', - MODIFICATION: 'MODIFICATION', - NO_CHANGE: 'NONE', -}; - -describe('Design management list item component', () => { - let wrapper; - - const findDesignEvent = () => wrapper.find('[data-testid="designEvent"]'); - const findEventIcon = () => findDesignEvent().find(Icon); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - - function createComponent({ - notesCount = 0, - event = DESIGN_VERSION_EVENT.NO_CHANGE, - isUploading = false, - imageLoading = false, - } = {}) { - wrapper = shallowMount(Item, { - localVue, - router, - propsData: { - id: 1, - filename: 'test', - image: 'http://via.placeholder.com/300', - isUploading, - event, - notesCount, - updatedAt: '01-01-2019', - }, - data() { - return { - imageLoading, - }; - }, - stubs: ['router-link'], - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when item is not in view', () => { - it('image is not rendered', () => { - createComponent(); - - const image = wrapper.find('img'); - expect(image.attributes('src')).toBe(''); - }); - }); - - describe('when item appears in view', () => { - let image; - let glIntersectionObserver; - - beforeEach(() => { - createComponent(); - image = wrapper.find('img'); - glIntersectionObserver = wrapper.find(GlIntersectionObserver); - - glIntersectionObserver.vm.$emit('appear'); - return wrapper.vm.$nextTick(); - }); - - describe('before image is loaded', () => { - it('renders loading spinner', () => { - expect(wrapper.find(GlLoadingIcon)).toExist(); - }); - }); - - describe('after image is loaded', () => { - beforeEach(() => { - image.trigger('load'); - return wrapper.vm.$nextTick(); - }); - - it('renders an image', () => { - expect(image.attributes('src')).toBe('http://via.placeholder.com/300'); - expect(image.isVisible()).toBe(true); - }); - - it('renders media broken icon when image onerror triggered', () => { - image.trigger('error'); - return wrapper.vm.$nextTick().then(() => { - expect(image.isVisible()).toBe(false); - expect(wrapper.find(GlIcon).element).toMatchSnapshot(); - }); - }); - - describe('when imageV432x230 and image provided', () => { - it('renders imageV432x230 image', () => { - const mockSrc = 'mock-imageV432x230-url'; - wrapper.setProps({ imageV432x230: mockSrc }); - - return wrapper.vm.$nextTick().then(() => { - expect(image.attributes('src')).toBe(mockSrc); - }); - }); - }); - - describe('when image disappears from view and then reappears', () => { - beforeEach(() => { - glIntersectionObserver.vm.$emit('appear'); - return wrapper.vm.$nextTick(); - }); - - it('renders an image', () => { - expect(image.isVisible()).toBe(true); - }); - }); - }); - }); - - describe('with notes', () => { - it('renders item with single comment', () => { - createComponent({ notesCount: 1 }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders item with multiple comments', () => { - createComponent({ notesCount: 2 }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders loading spinner when isUploading is true', () => { - createComponent({ isUploading: true }); - - expect(findLoadingIcon().exists()).toBe(true); - }); - - it('renders item with no status icon for none event', () => { - createComponent(); - - expect(findDesignEvent().exists()).toBe(false); - }); - - describe('with associated event', () => { - it.each` - event | icon | className - ${DESIGN_VERSION_EVENT.MODIFICATION} | ${'file-modified-solid'} | ${'text-primary-500'} - ${DESIGN_VERSION_EVENT.DELETION} | ${'file-deletion-solid'} | ${'text-danger-500'} - ${DESIGN_VERSION_EVENT.CREATION} | ${'file-addition-solid'} | ${'text-success-500'} - `('renders item with correct status icon for $event event', ({ event, icon, className }) => { - createComponent({ event }); - const eventIcon = findEventIcon(); - - expect(eventIcon.exists()).toBe(true); - expect(eventIcon.props('name')).toBe(icon); - expect(eventIcon.classes()).toContain(className); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap deleted file mode 100644 index e55cff8de3d..00000000000 --- a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap +++ /dev/null @@ -1,61 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management toolbar component renders design and updated data 1`] = ` -<header - class="d-flex p-2 bg-white align-items-center js-design-header" -> - <a - aria-label="Go back to designs" - class="mr-3 text-plain d-flex justify-content-center align-items-center" - > - <icon-stub - name="close" - size="18" - /> - </a> - - <div - class="overflow-hidden d-flex align-items-center" - > - <h2 - class="m-0 str-truncated-100 gl-font-base" - > - test.jpg - </h2> - - <small - class="text-secondary" - > - Updated 1 hour ago by Test Name - </small> - </div> - - <pagination-stub - class="ml-auto flex-shrink-0" - id="1" - /> - - <gl-deprecated-button-stub - class="mr-2" - href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" - size="md" - variant="secondary" - > - <icon-stub - name="download" - size="18" - /> - </gl-deprecated-button-stub> - - <delete-button-stub - buttonclass="" - buttonvariant="danger" - hasselecteddesigns="true" - > - <icon-stub - name="remove" - size="18" - /> - </delete-button-stub> -</header> -`; diff --git a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap deleted file mode 100644 index 08662a04f15..00000000000 --- a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap +++ /dev/null @@ -1,28 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management pagination button component disables button when no design is passed 1`] = ` -<router-link-stub - aria-label="Test title" - class="btn btn-default disabled" - disabled="true" - to="[object Object]" -> - <icon-stub - name="angle-right" - size="16" - /> -</router-link-stub> -`; - -exports[`Design management pagination button component renders router-link 1`] = ` -<router-link-stub - aria-label="Test title" - class="btn btn-default" - to="[object Object]" -> - <icon-stub - name="angle-right" - size="16" - /> -</router-link-stub> -`; diff --git a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap deleted file mode 100644 index 0197b4bff79..00000000000 --- a/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap +++ /dev/null @@ -1,29 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`; - -exports[`Design management pagination component renders pagination buttons 1`] = ` -<div - class="d-flex align-items-center" -> - - 0 of 2 - - <div - class="btn-group ml-3 mr-3" - > - <pagination-button-stub - class="js-previous-design" - iconname="angle-left" - title="Go to previous design" - /> - - <pagination-button-stub - class="js-next-design" - design="[object Object]" - iconname="angle-right" - title="Go to next design" - /> - </div> -</div> -`; diff --git a/spec/frontend/design_management_legacy/components/toolbar/index_spec.js b/spec/frontend/design_management_legacy/components/toolbar/index_spec.js deleted file mode 100644 index 8207cad4136..00000000000 --- a/spec/frontend/design_management_legacy/components/toolbar/index_spec.js +++ /dev/null @@ -1,123 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import VueRouter from 'vue-router'; -import { GlDeprecatedButton } from '@gitlab/ui'; -import Toolbar from '~/design_management_legacy/components/toolbar/index.vue'; -import DeleteButton from '~/design_management_legacy/components/delete_button.vue'; -import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants'; - -const localVue = createLocalVue(); -localVue.use(VueRouter); -const router = new VueRouter(); - -const RouterLinkStub = { - props: { - to: { - type: Object, - }, - }, - render(createElement) { - return createElement('a', {}, this.$slots.default); - }, -}; - -describe('Design management toolbar component', () => { - let wrapper; - - function createComponent(isLoading = false, createDesign = true, props) { - const updatedAt = new Date(); - updatedAt.setHours(updatedAt.getHours() - 1); - - wrapper = shallowMount(Toolbar, { - localVue, - router, - propsData: { - id: '1', - isLatestVersion: true, - isLoading, - isDeleting: false, - filename: 'test.jpg', - updatedAt: updatedAt.toString(), - updatedBy: { - name: 'Test Name', - }, - image: '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', - ...props, - }, - stubs: { - 'router-link': RouterLinkStub, - }, - }); - - wrapper.setData({ - permissions: { - createDesign, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders design and updated data', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('links back to designs list', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - const link = wrapper.find('a'); - - expect(link.props('to')).toEqual({ - name: DESIGNS_ROUTE_NAME, - query: { - version: undefined, - }, - }); - }); - }); - - it('renders delete button on latest designs version with logged in user', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DeleteButton).exists()).toBe(true); - }); - }); - - it('does not render delete button on non-latest version', () => { - createComponent(false, true, { isLatestVersion: false }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DeleteButton).exists()).toBe(false); - }); - }); - - it('does not render delete button when user is not logged in', () => { - createComponent(false, false); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DeleteButton).exists()).toBe(false); - }); - }); - - it('emits `delete` event on deleteButton `deleteSelectedDesigns` event', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - wrapper.find(DeleteButton).vm.$emit('deleteSelectedDesigns'); - expect(wrapper.emitted().delete).toBeTruthy(); - }); - }); - - it('renders download button with correct link', () => { - expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe( - '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', - ); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js deleted file mode 100644 index d2153adca45..00000000000 --- a/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js +++ /dev/null @@ -1,61 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import VueRouter from 'vue-router'; -import PaginationButton from '~/design_management_legacy/components/toolbar/pagination_button.vue'; -import { DESIGN_ROUTE_NAME } from '~/design_management_legacy/router/constants'; - -const localVue = createLocalVue(); -localVue.use(VueRouter); -const router = new VueRouter(); - -describe('Design management pagination button component', () => { - let wrapper; - - function createComponent(design = null) { - wrapper = shallowMount(PaginationButton, { - localVue, - router, - propsData: { - design, - title: 'Test title', - iconName: 'angle-right', - }, - stubs: ['router-link'], - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('disables button when no design is passed', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders router-link', () => { - createComponent({ id: '2' }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('designLink', () => { - it('returns empty link when design is null', () => { - createComponent(); - - expect(wrapper.vm.designLink).toEqual({}); - }); - - it('returns design link', () => { - createComponent({ id: '2', filename: 'test' }); - - wrapper.vm.$router.replace('/root/test-project/issues/1/designs/test?version=1'); - - expect(wrapper.vm.designLink).toEqual({ - name: DESIGN_ROUTE_NAME, - params: { id: 'test' }, - query: { version: '1' }, - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js b/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js deleted file mode 100644 index 21b55113a6e..00000000000 --- a/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -/* global Mousetrap */ -import 'mousetrap'; -import { shallowMount } from '@vue/test-utils'; -import Pagination from '~/design_management_legacy/components/toolbar/pagination.vue'; -import { DESIGN_ROUTE_NAME } from '~/design_management_legacy/router/constants'; - -const push = jest.fn(); -const $router = { - push, -}; - -const $route = { - path: '/designs/design-2', - query: {}, -}; - -describe('Design management pagination component', () => { - let wrapper; - - function createComponent() { - wrapper = shallowMount(Pagination, { - propsData: { - id: '2', - }, - mocks: { - $router, - $route, - }, - }); - } - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('hides components when designs are empty', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders pagination buttons', () => { - wrapper.setData({ - designs: [{ id: '1' }, { id: '2' }], - }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('keyboard buttons navigation', () => { - beforeEach(() => { - wrapper.setData({ - designs: [{ filename: '1' }, { filename: '2' }, { filename: '3' }], - }); - }); - - it('routes to previous design on Left button', () => { - Mousetrap.trigger('left'); - expect(push).toHaveBeenCalledWith({ - name: DESIGN_ROUTE_NAME, - params: { id: '1' }, - query: {}, - }); - }); - - it('routes to next design on Right button', () => { - Mousetrap.trigger('right'); - expect(push).toHaveBeenCalledWith({ - name: DESIGN_ROUTE_NAME, - params: { id: '3' }, - query: {}, - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap deleted file mode 100644 index 27c0ba589e6..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap +++ /dev/null @@ -1,79 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management upload button component renders inverted upload design button 1`] = ` -<div - isinverted="true" -> - <gl-deprecated-button-stub - size="md" - title="Adding a design with the same filename replaces the file in a new version." - variant="success" - > - - Upload designs - - <!----> - </gl-deprecated-button-stub> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> -</div> -`; - -exports[`Design management upload button component renders loading icon 1`] = ` -<div> - <gl-deprecated-button-stub - disabled="true" - size="md" - title="Adding a design with the same filename replaces the file in a new version." - variant="success" - > - - Upload designs - - <gl-loading-icon-stub - class="ml-1" - color="orange" - inline="true" - label="Loading" - size="sm" - /> - </gl-deprecated-button-stub> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> -</div> -`; - -exports[`Design management upload button component renders upload design button 1`] = ` -<div> - <gl-deprecated-button-stub - size="md" - title="Adding a design with the same filename replaces the file in a new version." - variant="success" - > - - Upload designs - - <!----> - </gl-deprecated-button-stub> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> -</div> -`; diff --git a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap deleted file mode 100644 index 0737b9729a2..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap +++ /dev/null @@ -1,455 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management dropzone component when dragging renders correct template when drag event contains files 1`] = ` -<div - class="w-100 position-relative" -> - <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" - > - <div - class="d-flex-center flex-column text-center" - > - <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" - /> - - <p> - <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." - /> - </p> - </div> - </button> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="" - > - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; - -exports[`Design management dropzone component when dragging renders correct template when drag event contains files and text 1`] = ` -<div - class="w-100 position-relative" -> - <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" - > - <div - class="d-flex-center flex-column text-center" - > - <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" - /> - - <p> - <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." - /> - </p> - </div> - </button> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="" - > - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; - -exports[`Design management dropzone component when dragging renders correct template when drag event contains text 1`] = ` -<div - class="w-100 position-relative" -> - <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" - > - <div - class="d-flex-center flex-column text-center" - > - <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" - /> - - <p> - <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." - /> - </p> - </div> - </button> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="" - > - <div - class="mw-50 text-center" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; - -exports[`Design management dropzone component when dragging renders correct template when drag event is empty 1`] = ` -<div - class="w-100 position-relative" -> - <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" - > - <div - class="d-flex-center flex-column text-center" - > - <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" - /> - - <p> - <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." - /> - </p> - </div> - </button> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="" - > - <div - class="mw-50 text-center" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; - -exports[`Design management dropzone component when dragging renders correct template when dragging stops 1`] = ` -<div - class="w-100 position-relative" -> - <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" - > - <div - class="d-flex-center flex-column text-center" - > - <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" - /> - - <p> - <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." - /> - </p> - </div> - </button> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="display: none;" - > - <div - class="mw-50 text-center" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; - -exports[`Design management dropzone component when no slot provided renders default dropzone card 1`] = ` -<div - class="w-100 position-relative" -> - <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" - > - <div - class="d-flex-center flex-column text-center" - > - <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" - /> - - <p> - <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." - /> - </p> - </div> - </button> - - <input - accept="image/*" - class="hide" - multiple="multiple" - name="design_file" - type="file" - /> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="display: none;" - > - <div - class="mw-50 text-center" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; - -exports[`Design management dropzone component when slot provided renders dropzone with slot content 1`] = ` -<div - class="w-100 position-relative" -> - <div> - dropzone slot - </div> - - <transition-stub - name="design-dropzone-fade" - > - <div - class="card design-dropzone-border design-dropzone-overlay w-100 h-100 position-absolute d-flex-center p-3 bg-white" - style="display: none;" - > - <div - class="mw-50 text-center" - > - <h3> - Oh no! - </h3> - - <span> - You are trying to upload something other than an image. Please upload a .png, .jpg, .jpeg, .gif, .bmp, .tiff or .ico. - </span> - </div> - - <div - class="mw-50 text-center" - style="display: none;" - > - <h3> - Incoming! - </h3> - - <span> - Drop your designs to start your upload. - </span> - </div> - </div> - </transition-stub> -</div> -`; diff --git a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap deleted file mode 100644 index d34b925f33d..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ /dev/null @@ -1,111 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` -<gl-deprecated-dropdown-stub - class="design-version-dropdown" - issueiid="" - projectpath="" - text="Showing Latest Version" - variant="link" -> - <gl-deprecated-dropdown-item-stub> - <router-link-stub - class="d-flex js-version-link" - to="[object Object]" - > - <div - class="flex-grow-1 ml-2" - > - <div> - <strong> - Version 2 - - <span> - (latest) - </span> - </strong> - </div> - </div> - - <i - class="fa fa-check float-right gl-mr-2" - /> - </router-link-stub> - </gl-deprecated-dropdown-item-stub> - <gl-deprecated-dropdown-item-stub> - <router-link-stub - class="d-flex js-version-link" - to="[object Object]" - > - <div - class="flex-grow-1 ml-2" - > - <div> - <strong> - Version 1 - - <!----> - </strong> - </div> - </div> - - <!----> - </router-link-stub> - </gl-deprecated-dropdown-item-stub> -</gl-deprecated-dropdown-stub> -`; - -exports[`Design management design version dropdown component renders design version list 1`] = ` -<gl-deprecated-dropdown-stub - class="design-version-dropdown" - issueiid="" - projectpath="" - text="Showing Latest Version" - variant="link" -> - <gl-deprecated-dropdown-item-stub> - <router-link-stub - class="d-flex js-version-link" - to="[object Object]" - > - <div - class="flex-grow-1 ml-2" - > - <div> - <strong> - Version 2 - - <span> - (latest) - </span> - </strong> - </div> - </div> - - <i - class="fa fa-check float-right gl-mr-2" - /> - </router-link-stub> - </gl-deprecated-dropdown-item-stub> - <gl-deprecated-dropdown-item-stub> - <router-link-stub - class="d-flex js-version-link" - to="[object Object]" - > - <div - class="flex-grow-1 ml-2" - > - <div> - <strong> - Version 1 - - <!----> - </strong> - </div> - </div> - - <!----> - </router-link-stub> - </gl-deprecated-dropdown-item-stub> -</gl-deprecated-dropdown-stub> -`; diff --git a/spec/frontend/design_management_legacy/components/upload/button_spec.js b/spec/frontend/design_management_legacy/components/upload/button_spec.js deleted file mode 100644 index dde5c694194..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/button_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import UploadButton from '~/design_management_legacy/components/upload/button.vue'; - -describe('Design management upload button component', () => { - let wrapper; - - function createComponent(isSaving = false, isInverted = false) { - wrapper = shallowMount(UploadButton, { - propsData: { - isSaving, - isInverted, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders upload design button', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders inverted upload design button', () => { - createComponent(false, true); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders loading icon', () => { - createComponent(true); - - expect(wrapper.element).toMatchSnapshot(); - }); - - describe('onFileUploadChange', () => { - it('emits upload event', () => { - createComponent(); - - wrapper.vm.onFileUploadChange({ target: { files: 'test' } }); - - expect(wrapper.emitted().upload[0]).toEqual(['test']); - }); - }); - - describe('openFileUpload', () => { - it('triggers click on input', () => { - createComponent(); - - const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); - - wrapper.vm.openFileUpload(); - - expect(clickSpy).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js b/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js deleted file mode 100644 index 1907a3124a6..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js +++ /dev/null @@ -1,132 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import DesignDropzone from '~/design_management_legacy/components/upload/design_dropzone.vue'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; - -jest.mock('~/flash'); - -describe('Design management dropzone component', () => { - let wrapper; - - const mockDragEvent = ({ types = ['Files'], files = [] }) => { - return { dataTransfer: { types, files } }; - }; - - const findDropzoneCard = () => wrapper.find('.design-dropzone-card'); - - function createComponent({ slots = {}, data = {} } = {}) { - wrapper = shallowMount(DesignDropzone, { - slots, - data() { - return data; - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when slot provided', () => { - it('renders dropzone with slot content', () => { - createComponent({ - slots: { - default: ['<div>dropzone slot</div>'], - }, - }); - - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('when no slot provided', () => { - it('renders default dropzone card', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('triggers click event on file input element when clicked', () => { - createComponent(); - const clickSpy = jest.spyOn(wrapper.find('input').element, 'click'); - - findDropzoneCard().trigger('click'); - expect(clickSpy).toHaveBeenCalled(); - }); - }); - - describe('when dragging', () => { - it.each` - description | eventPayload - ${'is empty'} | ${{}} - ${'contains text'} | ${mockDragEvent({ types: ['text'] })} - ${'contains files and text'} | ${mockDragEvent({ types: ['Files', 'text'] })} - ${'contains files'} | ${mockDragEvent({ types: ['Files'] })} - `('renders correct template when drag event $description', ({ eventPayload }) => { - createComponent(); - - wrapper.trigger('dragenter', eventPayload); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders correct template when dragging stops', () => { - createComponent(); - - wrapper.trigger('dragenter'); - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.trigger('dragleave'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - }); - - describe('when dropping', () => { - it('emits upload event', () => { - createComponent(); - const mockFile = { name: 'test', type: 'image/jpg' }; - const mockEvent = mockDragEvent({ files: [mockFile] }); - - wrapper.trigger('dragenter', mockEvent); - return wrapper.vm - .$nextTick() - .then(() => { - wrapper.trigger('drop', mockEvent); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); - }); - }); - }); - - describe('ondrop', () => { - const mockData = { dragCounter: 1, isDragDataValid: true }; - - describe('when drag data is valid', () => { - it('emits upload event for valid files', () => { - createComponent({ data: mockData }); - - const mockFile = { type: 'image/jpg' }; - const mockEvent = mockDragEvent({ files: [mockFile] }); - - wrapper.vm.ondrop(mockEvent); - expect(wrapper.emitted().change[0]).toEqual([[mockFile]]); - }); - - it('calls createFlash when files are invalid', () => { - createComponent({ data: mockData }); - - const mockEvent = mockDragEvent({ files: [{ type: 'audio/midi' }] }); - - wrapper.vm.ondrop(mockEvent); - expect(createFlash).toHaveBeenCalledTimes(1); - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js deleted file mode 100644 index 7fb85f357c7..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js +++ /dev/null @@ -1,122 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; -import DesignVersionDropdown from '~/design_management_legacy/components/upload/design_version_dropdown.vue'; -import mockAllVersions from './mock_data/all_versions'; - -const LATEST_VERSION_ID = 3; -const PREVIOUS_VERSION_ID = 2; - -const designRouteFactory = versionId => ({ - path: `/designs?version=${versionId}`, - query: { - version: `${versionId}`, - }, -}); - -const MOCK_ROUTE = { - path: '/designs', - query: {}, -}; - -describe('Design management design version dropdown component', () => { - let wrapper; - - function createComponent({ maxVersions = -1, $route = MOCK_ROUTE } = {}) { - wrapper = shallowMount(DesignVersionDropdown, { - propsData: { - projectPath: '', - issueIid: '', - }, - mocks: { - $route, - }, - stubs: ['router-link'], - }); - - wrapper.setData({ - allVersions: maxVersions > -1 ? mockAllVersions.slice(0, maxVersions) : mockAllVersions, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - const findVersionLink = index => wrapper.findAll('.js-version-link').at(index); - - it('renders design version dropdown button', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders design version list', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('selected version name', () => { - it('has "latest" on most recent version item', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(findVersionLink(0).text()).toContain('latest'); - }); - }); - }); - - describe('versions list', () => { - it('displays latest version text by default', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe( - 'Showing Latest Version', - ); - }); - }); - - it('displays latest version text when only 1 version is present', () => { - createComponent({ maxVersions: 1 }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe( - 'Showing Latest Version', - ); - }); - }); - - it('displays version text when the current version is not the latest', () => { - createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe(`Showing Version #1`); - }); - }); - - it('displays latest version text when the current version is the latest', () => { - createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlDeprecatedDropdown).attributes('text')).toBe( - 'Showing Latest Version', - ); - }); - }); - - it('should have the same length as apollo query', () => { - createComponent(); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.findAll(GlDeprecatedDropdownItem)).toHaveLength( - wrapper.vm.allVersions.length, - ); - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js b/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js deleted file mode 100644 index e76bbd261bd..00000000000 --- a/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js +++ /dev/null @@ -1,14 +0,0 @@ -export default [ - { - node: { - id: 'gid://gitlab/DesignManagement::Version/3', - sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55', - }, - }, - { - node: { - id: 'gid://gitlab/DesignManagement::Version/2', - sha: '5b063fef0cd7213b312db65b30e24f057df21b20', - }, - }, -]; diff --git a/spec/frontend/design_management_legacy/mock_data/all_versions.js b/spec/frontend/design_management_legacy/mock_data/all_versions.js deleted file mode 100644 index c389fdb8747..00000000000 --- a/spec/frontend/design_management_legacy/mock_data/all_versions.js +++ /dev/null @@ -1,8 +0,0 @@ -export default [ - { - node: { - id: 'gid://gitlab/DesignManagement::Version/1', - sha: 'b389071a06c153509e11da1f582005b316667001', - }, - }, -]; diff --git a/spec/frontend/design_management_legacy/mock_data/design.js b/spec/frontend/design_management_legacy/mock_data/design.js deleted file mode 100644 index 675198b9408..00000000000 --- a/spec/frontend/design_management_legacy/mock_data/design.js +++ /dev/null @@ -1,74 +0,0 @@ -export default { - id: 'design-id', - filename: 'test.jpg', - fullPath: 'full-design-path', - image: 'test.jpg', - updatedAt: '01-01-2019', - updatedBy: { - name: 'test', - }, - issue: { - title: 'My precious issue', - webPath: 'full-issue-path', - webUrl: 'full-issue-url', - participants: { - edges: [ - { - node: { - name: 'Administrator', - username: 'root', - webUrl: 'link-to-author', - avatarUrl: 'link-to-avatar', - }, - }, - ], - }, - }, - discussions: { - nodes: [ - { - id: 'discussion-id', - replyId: 'discussion-reply-id', - resolved: false, - notes: { - nodes: [ - { - id: 'note-id', - body: '123', - author: { - name: 'Administrator', - username: 'root', - webUrl: 'link-to-author', - avatarUrl: 'link-to-avatar', - }, - }, - ], - }, - }, - { - id: 'discussion-resolved', - replyId: 'discussion-reply-resolved', - resolved: true, - notes: { - nodes: [ - { - id: 'note-resolved', - body: '123', - author: { - name: 'Administrator', - username: 'root', - webUrl: 'link-to-author', - avatarUrl: 'link-to-avatar', - }, - }, - ], - }, - }, - ], - }, - diffRefs: { - headSha: 'headSha', - baseSha: 'baseSha', - startSha: 'startSha', - }, -}; diff --git a/spec/frontend/design_management_legacy/mock_data/designs.js b/spec/frontend/design_management_legacy/mock_data/designs.js deleted file mode 100644 index 07f5c1b7457..00000000000 --- a/spec/frontend/design_management_legacy/mock_data/designs.js +++ /dev/null @@ -1,17 +0,0 @@ -import design from './design'; - -export default { - project: { - issue: { - designCollection: { - designs: { - edges: [ - { - node: design, - }, - ], - }, - }, - }, - }, -}; diff --git a/spec/frontend/design_management_legacy/mock_data/no_designs.js b/spec/frontend/design_management_legacy/mock_data/no_designs.js deleted file mode 100644 index 9db0ffcade2..00000000000 --- a/spec/frontend/design_management_legacy/mock_data/no_designs.js +++ /dev/null @@ -1,11 +0,0 @@ -export default { - project: { - issue: { - designCollection: { - designs: { - edges: [], - }, - }, - }, - }, -}; diff --git a/spec/frontend/design_management_legacy/mock_data/notes.js b/spec/frontend/design_management_legacy/mock_data/notes.js deleted file mode 100644 index 80cb3944786..00000000000 --- a/spec/frontend/design_management_legacy/mock_data/notes.js +++ /dev/null @@ -1,46 +0,0 @@ -export default [ - { - id: 'note-id-1', - index: 1, - position: { - height: 100, - width: 100, - x: 10, - y: 15, - }, - author: { - name: 'John', - webUrl: 'link-to-john-profile', - }, - createdAt: '2020-05-08T07:10:45Z', - userPermissions: { - adminNote: true, - }, - discussion: { - id: 'discussion-id-1', - }, - resolved: false, - }, - { - id: 'note-id-2', - index: 2, - position: { - height: 50, - width: 50, - x: 25, - y: 25, - }, - author: { - name: 'Mary', - webUrl: 'link-to-mary-profile', - }, - createdAt: '2020-05-08T07:10:45Z', - userPermissions: { - adminNote: true, - }, - discussion: { - id: 'discussion-id-2', - }, - resolved: true, - }, -]; diff --git a/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap deleted file mode 100644 index 3ba63fd14f0..00000000000 --- a/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap +++ /dev/null @@ -1,263 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management index page designs does not render toolbar when there is no permission 1`] = ` -<div> - <!----> - - <div - class="mt-4" - > - <ol - class="list-unstyled row" - > - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub - class="design-list-item" - /> - </li> - - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub> - <design-stub - event="NONE" - filename="design-1-name" - id="design-1" - image="design-1-image" - notescount="0" - /> - </design-dropzone-stub> - - <!----> - </li> - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub> - <design-stub - event="NONE" - filename="design-2-name" - id="design-2" - image="design-2-image" - notescount="1" - /> - </design-dropzone-stub> - - <!----> - </li> - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub> - <design-stub - event="NONE" - filename="design-3-name" - id="design-3" - image="design-3-image" - notescount="0" - /> - </design-dropzone-stub> - - <!----> - </li> - </ol> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - -exports[`Design management index page designs renders designs list and header with upload button 1`] = ` -<div> - <header - class="row-content-block border-top-0 p-2 d-flex" - > - <div - class="d-flex justify-content-between align-items-center w-100" - > - <design-version-dropdown-stub /> - - <div - class="qa-selector-toolbar d-flex" - > - <gl-deprecated-button-stub - class="mr-2 js-select-all" - size="md" - variant="link" - > - Select all - </gl-deprecated-button-stub> - - <div> - <delete-button-stub - buttonclass="btn-danger btn-inverted mr-2" - buttonvariant="" - > - - Delete selected - - <!----> - </delete-button-stub> - </div> - - <upload-button-stub /> - </div> - </div> - </header> - - <div - class="mt-4" - > - <ol - class="list-unstyled row" - > - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub - class="design-list-item" - /> - </li> - - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub> - <design-stub - event="NONE" - filename="design-1-name" - id="design-1" - image="design-1-image" - notescount="0" - /> - </design-dropzone-stub> - - <input - class="design-checkbox" - type="checkbox" - /> - </li> - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub> - <design-stub - event="NONE" - filename="design-2-name" - id="design-2" - image="design-2-image" - notescount="1" - /> - </design-dropzone-stub> - - <input - class="design-checkbox" - type="checkbox" - /> - </li> - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub> - <design-stub - event="NONE" - filename="design-3-name" - id="design-3" - image="design-3-image" - notescount="0" - /> - </design-dropzone-stub> - - <input - class="design-checkbox" - type="checkbox" - /> - </li> - </ol> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - -exports[`Design management index page designs renders error 1`] = ` -<div> - <!----> - - <div - class="mt-4" - > - <gl-alert-stub - dismisslabel="Dismiss" - primarybuttonlink="" - primarybuttontext="" - secondarybuttonlink="" - secondarybuttontext="" - title="" - variant="danger" - > - - An error occurred while loading designs. Please try again. - - </gl-alert-stub> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - -exports[`Design management index page designs renders loading icon 1`] = ` -<div> - <!----> - - <div - class="mt-4" - > - <gl-loading-icon-stub - color="orange" - label="Loading" - size="md" - /> - </div> - - <router-view-stub - name="default" - /> -</div> -`; - -exports[`Design management index page when has no designs renders empty text 1`] = ` -<div> - <!----> - - <div - class="mt-4" - > - <ol - class="list-unstyled row" - > - <li - class="col-md-6 col-lg-4 mb-3" - > - <design-dropzone-stub - class="design-list-item" - /> - </li> - - </ol> - </div> - - <router-view-stub - name="default" - /> -</div> -`; diff --git a/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap deleted file mode 100644 index dc5baf37fc6..00000000000 --- a/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap +++ /dev/null @@ -1,216 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Design management design index page renders design index 1`] = ` -<div - class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" -> - <div - class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" - > - <design-destroyer-stub - filenames="test.jpg" - iid="1" - projectpath="" - /> - - <!----> - - <design-presentation-stub - discussions="[object Object],[object Object]" - image="test.jpg" - imagename="test.jpg" - scale="1" - /> - - <div - class="design-scaler-wrapper position-absolute mb-4 d-flex-center" - > - <design-scaler-stub /> - </div> - </div> - - <div - class="image-notes" - > - <h2 - class="gl-font-weight-bold gl-mt-0" - > - - My precious issue - - </h2> - - <a - class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" - href="full-issue-url" - > - ull-issue-path - </a> - - <participants-stub - class="gl-mb-4" - numberoflessparticipants="7" - participants="[object Object]" - /> - - <!----> - - <design-discussion-stub - data-testid="unresolved-discussion" - designid="test" - discussion="[object Object]" - discussionwithopenform="" - markdownpreviewpath="//preview_markdown?target_type=Issue" - noteableid="design-id" - /> - - <gl-button-stub - category="primary" - class="link-inherit-color gl-text-body gl-text-decoration-none gl-font-weight-bold gl-mb-4" - data-testid="resolved-comments" - icon="chevron-right" - id="resolved-comments" - size="medium" - variant="link" - > - Resolved Comments (1) - - </gl-button-stub> - - <gl-popover-stub - container="popovercontainer" - cssclasses="" - placement="top" - show="true" - target="resolved-comments" - title="Resolved Comments" - > - <p> - - Comments you resolve can be viewed and unresolved by going to the "Resolved Comments" section below - - </p> - - <a - href="#" - rel="noopener noreferrer" - target="_blank" - > - Learn more about resolving comments - </a> - </gl-popover-stub> - - <gl-collapse-stub - class="gl-mt-3" - > - <design-discussion-stub - data-testid="resolved-discussion" - designid="test" - discussion="[object Object]" - discussionwithopenform="" - markdownpreviewpath="//preview_markdown?target_type=Issue" - noteableid="design-id" - /> - </gl-collapse-stub> - - </div> -</div> -`; - -exports[`Design management design index page sets loading state 1`] = ` -<div - class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" -> - <gl-loading-icon-stub - class="align-self-center" - color="orange" - label="Loading" - size="xl" - /> -</div> -`; - -exports[`Design management design index page with error GlAlert is rendered in correct position with correct content 1`] = ` -<div - class="design-detail js-design-detail fixed-top w-100 position-bottom-0 d-flex justify-content-center flex-column flex-lg-row" -> - <div - class="d-flex overflow-hidden flex-grow-1 flex-column position-relative" - > - <design-destroyer-stub - filenames="test.jpg" - iid="1" - projectpath="" - /> - - <div - class="p-3" - > - <gl-alert-stub - dismissible="true" - dismisslabel="Dismiss" - primarybuttonlink="" - primarybuttontext="" - secondarybuttonlink="" - secondarybuttontext="" - title="" - variant="danger" - > - - woops - - </gl-alert-stub> - </div> - - <design-presentation-stub - discussions="" - image="test.jpg" - imagename="test.jpg" - scale="1" - /> - - <div - class="design-scaler-wrapper position-absolute mb-4 d-flex-center" - > - <design-scaler-stub /> - </div> - </div> - - <div - class="image-notes" - > - <h2 - class="gl-font-weight-bold gl-mt-0" - > - - My precious issue - - </h2> - - <a - class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" - href="full-issue-url" - > - ull-issue-path - </a> - - <participants-stub - class="gl-mb-4" - numberoflessparticipants="7" - participants="[object Object]" - /> - - <h2 - class="new-discussion-disclaimer gl-font-base gl-m-0 gl-mb-4" - data-testid="new-discussion-disclaimer" - > - - Click the image where you'd like to start a new discussion - - </h2> - - <!----> - - </div> -</div> -`; diff --git a/spec/frontend/design_management_legacy/pages/design/index_spec.js b/spec/frontend/design_management_legacy/pages/design/index_spec.js deleted file mode 100644 index 5eb4158c715..00000000000 --- a/spec/frontend/design_management_legacy/pages/design/index_spec.js +++ /dev/null @@ -1,291 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import VueRouter from 'vue-router'; -import { GlAlert } from '@gitlab/ui'; -import { ApolloMutation } from 'vue-apollo'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import DesignIndex from '~/design_management_legacy/pages/design/index.vue'; -import DesignSidebar from '~/design_management_legacy/components/design_sidebar.vue'; -import DesignPresentation from '~/design_management_legacy/components/design_presentation.vue'; -import createImageDiffNoteMutation from '~/design_management_legacy/graphql/mutations/create_image_diff_note.mutation.graphql'; -import design from '../../mock_data/design'; -import mockResponseWithDesigns from '../../mock_data/designs'; -import mockResponseNoDesigns from '../../mock_data/no_designs'; -import mockAllVersions from '../../mock_data/all_versions'; -import { - DESIGN_NOT_FOUND_ERROR, - DESIGN_VERSION_NOT_EXIST_ERROR, -} from '~/design_management_legacy/utils/error_messages'; -import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants'; -import createRouter from '~/design_management_legacy/router'; -import * as utils from '~/design_management_legacy/utils/design_management_utils'; -import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_legacy/constants'; - -jest.mock('~/flash'); -jest.mock('mousetrap', () => ({ - bind: jest.fn(), - unbind: jest.fn(), -})); - -const focusInput = jest.fn(); - -const DesignReplyForm = { - template: '<div><textarea ref="textarea"></textarea></div>', - methods: { - focusInput, - }, -}; - -const localVue = createLocalVue(); -localVue.use(VueRouter); - -describe('Design management design index page', () => { - let wrapper; - let router; - - const newComment = 'new comment'; - const annotationCoordinates = { - x: 10, - y: 10, - width: 100, - height: 100, - }; - const createDiscussionMutationVariables = { - mutation: createImageDiffNoteMutation, - update: expect.anything(), - variables: { - input: { - body: newComment, - noteableId: design.id, - position: { - headSha: 'headSha', - baseSha: 'baseSha', - startSha: 'startSha', - paths: { - newPath: 'full-design-path', - }, - ...annotationCoordinates, - }, - }, - }, - }; - - const mutate = jest.fn().mockResolvedValue(); - - const findDiscussionForm = () => wrapper.find(DesignReplyForm); - const findSidebar = () => wrapper.find(DesignSidebar); - const findDesignPresentation = () => wrapper.find(DesignPresentation); - - function createComponent(loading = false, data = {}) { - const $apollo = { - queries: { - design: { - loading, - }, - }, - mutate, - }; - - router = createRouter(); - - wrapper = shallowMount(DesignIndex, { - propsData: { id: '1' }, - mocks: { $apollo }, - stubs: { - ApolloMutation, - DesignSidebar, - DesignReplyForm, - }, - data() { - return { - issueIid: '1', - activeDiscussion: { - id: null, - source: null, - }, - ...data, - }; - }, - localVue, - router, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when navigating', () => { - it('applies fullscreen layout', () => { - const mockEl = { - classList: { - add: jest.fn(), - remove: jest.fn(), - }, - }; - jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockEl); - createComponent(true); - - wrapper.vm.$router.push('/designs/test'); - expect(mockEl.classList.add).toHaveBeenCalledTimes(1); - expect(mockEl.classList.add).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); - }); - }); - - it('sets loading state', () => { - createComponent(true); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders design index', () => { - createComponent(false, { design }); - - expect(wrapper.element).toMatchSnapshot(); - expect(wrapper.find(GlAlert).exists()).toBe(false); - }); - - it('passes correct props to sidebar component', () => { - createComponent(false, { design }); - - expect(findSidebar().props()).toEqual({ - design, - markdownPreviewPath: '//preview_markdown?target_type=Issue', - resolvedDiscussionsExpanded: false, - }); - }); - - it('opens a new discussion form', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], - }, - }, - }); - - findDesignPresentation().vm.$emit('openCommentForm', { x: 0, y: 0 }); - - return wrapper.vm.$nextTick().then(() => { - expect(findDiscussionForm().exists()).toBe(true); - }); - }); - - it('keeps new discussion form focused', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], - }, - }, - annotationCoordinates, - }); - - findDesignPresentation().vm.$emit('openCommentForm', { x: 10, y: 10 }); - - expect(focusInput).toHaveBeenCalled(); - }); - - it('sends a mutation on submitting form and closes form', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], - }, - }, - annotationCoordinates, - comment: newComment, - }); - - findDiscussionForm().vm.$emit('submitForm'); - expect(mutate).toHaveBeenCalledWith(createDiscussionMutationVariables); - - return wrapper.vm - .$nextTick() - .then(() => { - return mutate({ variables: createDiscussionMutationVariables }); - }) - .then(() => { - expect(findDiscussionForm().exists()).toBe(false); - }); - }); - - it('closes the form and clears the comment on canceling form', () => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], - }, - }, - annotationCoordinates, - comment: newComment, - }); - - findDiscussionForm().vm.$emit('cancelForm'); - - expect(wrapper.vm.comment).toBe(''); - - return wrapper.vm.$nextTick().then(() => { - expect(findDiscussionForm().exists()).toBe(false); - }); - }); - - describe('with error', () => { - beforeEach(() => { - createComponent(false, { - design: { - ...design, - discussions: { - nodes: [], - }, - }, - errorMessage: 'woops', - }); - }); - - it('GlAlert is rendered in correct position with correct content', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('onDesignQueryResult', () => { - describe('with no designs', () => { - it('redirects to /designs', () => { - createComponent(true); - router.push = jest.fn(); - - wrapper.vm.onDesignQueryResult({ data: mockResponseNoDesigns, loading: false }); - return wrapper.vm.$nextTick().then(() => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(DESIGN_NOT_FOUND_ERROR); - expect(router.push).toHaveBeenCalledTimes(1); - expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); - }); - }); - }); - - describe('when no design exists for given version', () => { - it('redirects to /designs', () => { - createComponent(true); - wrapper.setData({ - allVersions: mockAllVersions, - }); - - // attempt to query for a version of the design that doesn't exist - router.push({ query: { version: '999' } }); - router.push = jest.fn(); - - wrapper.vm.onDesignQueryResult({ data: mockResponseWithDesigns, loading: false }); - return wrapper.vm.$nextTick().then(() => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(DESIGN_VERSION_NOT_EXIST_ERROR); - expect(router.push).toHaveBeenCalledTimes(1); - expect(router.push).toHaveBeenCalledWith({ name: DESIGNS_ROUTE_NAME }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/pages/index_spec.js b/spec/frontend/design_management_legacy/pages/index_spec.js deleted file mode 100644 index 5b7512aab7b..00000000000 --- a/spec/frontend/design_management_legacy/pages/index_spec.js +++ /dev/null @@ -1,543 +0,0 @@ -import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { ApolloMutation } from 'vue-apollo'; -import VueRouter from 'vue-router'; -import { GlEmptyState } from '@gitlab/ui'; -import Index from '~/design_management_legacy/pages/index.vue'; -import uploadDesignQuery from '~/design_management_legacy/graphql/mutations/upload_design.mutation.graphql'; -import DesignDestroyer from '~/design_management_legacy/components/design_destroyer.vue'; -import DesignDropzone from '~/design_management_legacy/components/upload/design_dropzone.vue'; -import DeleteButton from '~/design_management_legacy/components/delete_button.vue'; -import { DESIGNS_ROUTE_NAME } from '~/design_management_legacy/router/constants'; -import { - EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, - EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, -} from '~/design_management_legacy/utils/error_messages'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; -import createRouter from '~/design_management_legacy/router'; -import * as utils from '~/design_management_legacy/utils/design_management_utils'; -import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_legacy/constants'; - -jest.mock('~/flash.js'); -const mockPageEl = { - classList: { - remove: jest.fn(), - }, -}; -jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageEl); - -const localVue = createLocalVue(); -const router = createRouter(); -localVue.use(VueRouter); - -const mockDesigns = [ - { - id: 'design-1', - image: 'design-1-image', - filename: 'design-1-name', - event: 'NONE', - notesCount: 0, - }, - { - id: 'design-2', - image: 'design-2-image', - filename: 'design-2-name', - event: 'NONE', - notesCount: 1, - }, - { - id: 'design-3', - image: 'design-3-image', - filename: 'design-3-name', - event: 'NONE', - notesCount: 0, - }, -]; - -const mockVersion = { - node: { - id: 'gid://gitlab/DesignManagement::Version/1', - }, -}; - -describe('Design management index page', () => { - let mutate; - let wrapper; - - const findDesignCheckboxes = () => wrapper.findAll('.design-checkbox'); - const findSelectAllButton = () => wrapper.find('.js-select-all'); - const findToolbar = () => wrapper.find('.qa-selector-toolbar'); - const findDeleteButton = () => wrapper.find(DeleteButton); - const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); - const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); - - function createComponent({ - loading = false, - designs = [], - allVersions = [], - createDesign = true, - stubs = {}, - mockMutate = jest.fn().mockResolvedValue(), - } = {}) { - mutate = mockMutate; - const $apollo = { - queries: { - designs: { - loading, - }, - permissions: { - loading, - }, - }, - mutate, - }; - - wrapper = shallowMount(Index, { - mocks: { $apollo }, - localVue, - router, - stubs: { DesignDestroyer, ApolloMutation, ...stubs }, - attachToDocument: true, - }); - - wrapper.setData({ - designs, - allVersions, - issueIid: '1', - permissions: { - createDesign, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - describe('designs', () => { - it('renders loading icon', () => { - createComponent({ loading: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders error', () => { - createComponent(); - - wrapper.setData({ error: true }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('renders a toolbar with buttons when there are designs', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - - return wrapper.vm.$nextTick().then(() => { - expect(findToolbar().exists()).toBe(true); - }); - }); - - it('renders designs list and header with upload button', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - it('does not render toolbar when there is no permission', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion], createDesign: false }); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - }); - - describe('when has no designs', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders empty text', () => - wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - })); - - it('does not render a toolbar with buttons', () => - wrapper.vm.$nextTick().then(() => { - expect(findToolbar().exists()).toBe(false); - })); - }); - - describe('uploading designs', () => { - it('calls mutation on upload', () => { - createComponent({ stubs: { GlEmptyState } }); - - const mutationVariables = { - update: expect.anything(), - context: { - hasUpload: true, - }, - mutation: uploadDesignQuery, - variables: { - files: [{ name: 'test' }], - projectPath: '', - iid: '1', - }, - optimisticResponse: { - __typename: 'Mutation', - designManagementUpload: { - __typename: 'DesignManagementUploadPayload', - designs: [ - { - __typename: 'Design', - id: expect.anything(), - image: '', - imageV432x230: '', - filename: 'test', - fullPath: '', - event: 'NONE', - notesCount: 0, - diffRefs: { - __typename: 'DiffRefs', - baseSha: '', - startSha: '', - headSha: '', - }, - discussions: { - __typename: 'DesignDiscussion', - nodes: [], - }, - versions: { - __typename: 'DesignVersionConnection', - edges: { - __typename: 'DesignVersionEdge', - node: { - __typename: 'DesignVersion', - id: expect.anything(), - sha: expect.anything(), - }, - }, - }, - }, - ], - skippedDesigns: [], - errors: [], - }, - }, - }; - - return wrapper.vm.$nextTick().then(() => { - findDropzone().vm.$emit('change', [{ name: 'test' }]); - expect(mutate).toHaveBeenCalledWith(mutationVariables); - expect(wrapper.vm.filesToBeSaved).toEqual([{ name: 'test' }]); - expect(wrapper.vm.isSaving).toBeTruthy(); - }); - }); - - it('sets isSaving', () => { - createComponent(); - - const uploadDesign = wrapper.vm.onUploadDesign([ - { - name: 'test', - }, - ]); - - expect(wrapper.vm.isSaving).toBe(true); - - return uploadDesign.then(() => { - expect(wrapper.vm.isSaving).toBe(false); - }); - }); - - it('updates state appropriately after upload complete', () => { - createComponent({ stubs: { GlEmptyState } }); - wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); - - wrapper.vm.onUploadDesignDone(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.filesToBeSaved).toEqual([]); - expect(wrapper.vm.isSaving).toBeFalsy(); - expect(wrapper.vm.isLatestVersion).toBe(true); - }); - }); - - it('updates state appropriately after upload error', () => { - createComponent({ stubs: { GlEmptyState } }); - wrapper.setData({ filesToBeSaved: [{ name: 'test' }] }); - - wrapper.vm.onUploadDesignError(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.filesToBeSaved).toEqual([]); - expect(wrapper.vm.isSaving).toBeFalsy(); - expect(createFlash).toHaveBeenCalled(); - - createFlash.mockReset(); - }); - }); - - it('does not call mutation if createDesign is false', () => { - createComponent({ createDesign: false }); - - wrapper.vm.onUploadDesign([]); - - expect(mutate).not.toHaveBeenCalled(); - }); - - describe('upload count limit', () => { - const MAXIMUM_FILE_UPLOAD_LIMIT = 10; - - afterEach(() => { - createFlash.mockReset(); - }); - - it('does not warn when the max files are uploaded', () => { - createComponent(); - - wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT).fill(mockDesigns[0])); - - expect(createFlash).not.toHaveBeenCalled(); - }); - - it('warns when too many files are uploaded', () => { - createComponent(); - - wrapper.vm.onUploadDesign(new Array(MAXIMUM_FILE_UPLOAD_LIMIT + 1).fill(mockDesigns[0])); - - expect(createFlash).toHaveBeenCalled(); - }); - }); - - it('flashes warning if designs are skipped', () => { - createComponent({ - mockMutate: () => - Promise.resolve({ - data: { designManagementUpload: { skippedDesigns: [{ filename: 'test.jpg' }] } }, - }), - }); - - const uploadDesign = wrapper.vm.onUploadDesign([ - { - name: 'test', - }, - ]); - - return uploadDesign.then(() => { - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Upload skipped. test.jpg did not change.', - 'warning', - ); - }); - }); - - describe('dragging onto an existing design', () => { - beforeEach(() => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - }); - - it('calls onUploadDesign with valid upload', () => { - wrapper.setMethods({ - onUploadDesign: jest.fn(), - }); - - const mockUploadPayload = [ - { - name: mockDesigns[0].filename, - }, - ]; - - const designDropzone = findFirstDropzoneWithDesign(); - designDropzone.vm.$emit('change', mockUploadPayload); - - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith(mockUploadPayload); - }); - - it.each` - description | eventPayload | message - ${'> 1 file'} | ${[{ name: 'test' }, { name: 'test-2' }]} | ${EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE} - ${'different filename'} | ${[{ name: 'wrong-name' }]} | ${EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE} - `('calls createFlash when upload has $description', ({ eventPayload, message }) => { - const designDropzone = findFirstDropzoneWithDesign(); - designDropzone.vm.$emit('change', eventPayload); - - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(message); - }); - }); - }); - - describe('on latest version when has designs', () => { - beforeEach(() => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - }); - - it('renders design checkboxes', () => { - expect(findDesignCheckboxes()).toHaveLength(mockDesigns.length); - }); - - it('renders toolbar buttons', () => { - expect(findToolbar().exists()).toBe(true); - expect(findToolbar().classes()).toContain('d-flex'); - expect(findToolbar().classes()).not.toContain('d-none'); - }); - - it('adds two designs to selected designs when their checkboxes are checked', () => { - findDesignCheckboxes() - .at(0) - .trigger('click'); - - return wrapper.vm - .$nextTick() - .then(() => { - findDesignCheckboxes() - .at(1) - .trigger('click'); - - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findDeleteButton().exists()).toBe(true); - expect(findSelectAllButton().text()).toBe('Deselect all'); - findDeleteButton().vm.$emit('deleteSelectedDesigns'); - const [{ variables }] = mutate.mock.calls[0]; - expect(variables.filenames).toStrictEqual([ - mockDesigns[0].filename, - mockDesigns[1].filename, - ]); - }); - }); - - it('adds all designs to selected designs when Select All button is clicked', () => { - findSelectAllButton().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(findDeleteButton().props().hasSelectedDesigns).toBe(true); - expect(findSelectAllButton().text()).toBe('Deselect all'); - expect(wrapper.vm.selectedDesigns).toEqual(mockDesigns.map(design => design.filename)); - }); - }); - - it('removes all designs from selected designs when at least one design was selected', () => { - findDesignCheckboxes() - .at(0) - .trigger('click'); - - return wrapper.vm - .$nextTick() - .then(() => { - findSelectAllButton().vm.$emit('click'); - }) - .then(() => { - expect(findDeleteButton().props().hasSelectedDesigns).toBe(false); - expect(findSelectAllButton().text()).toBe('Select all'); - expect(wrapper.vm.selectedDesigns).toEqual([]); - }); - }); - }); - - it('on latest version when has no designs does not render toolbar buttons', () => { - createComponent({ designs: [], allVersions: [mockVersion] }); - expect(findToolbar().exists()).toBe(false); - }); - - describe('on non-latest version', () => { - beforeEach(() => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - - router.replace({ - name: DESIGNS_ROUTE_NAME, - query: { - version: '2', - }, - }); - }); - - it('does not render design checkboxes', () => { - expect(findDesignCheckboxes()).toHaveLength(0); - }); - - it('does not render Delete selected button', () => { - expect(findDeleteButton().exists()).toBe(false); - }); - - it('does not render Select All button', () => { - expect(findSelectAllButton().exists()).toBe(false); - }); - }); - - describe('pasting a design', () => { - let event; - beforeEach(() => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - - wrapper.setMethods({ - onUploadDesign: jest.fn(), - }); - - event = new Event('paste'); - - router.replace({ - name: DESIGNS_ROUTE_NAME, - query: { - version: '2', - }, - }); - }); - - it('calls onUploadDesign with valid paste', () => { - event.clipboardData = { - files: [{ name: 'image.png', type: 'image/png' }], - getData: () => 'test.png', - }; - - document.dispatchEvent(event); - - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ - new File([{ name: 'image.png' }], 'test.png'), - ]); - }); - - it('renames a design if it has an image.png filename', () => { - event.clipboardData = { - files: [{ name: 'image.png', type: 'image/png' }], - getData: () => 'image.png', - }; - - document.dispatchEvent(event); - - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ - new File([{ name: 'image.png' }], `design_${Date.now()}.png`), - ]); - }); - - it('does not call onUploadDesign with invalid paste', () => { - event.clipboardData = { - items: [{ type: 'text/plain' }, { type: 'text' }], - files: [], - }; - - document.dispatchEvent(event); - - expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); - }); - }); - - describe('when navigating', () => { - it('ensures fullscreen layout is not applied', () => { - createComponent(true); - - wrapper.vm.$router.push('/designs'); - expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); - expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/router_spec.js b/spec/frontend/design_management_legacy/router_spec.js deleted file mode 100644 index 5f62793a243..00000000000 --- a/spec/frontend/design_management_legacy/router_spec.js +++ /dev/null @@ -1,82 +0,0 @@ -import { mount, createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import VueRouter from 'vue-router'; -import App from '~/design_management_legacy/components/app.vue'; -import Designs from '~/design_management_legacy/pages/index.vue'; -import DesignDetail from '~/design_management_legacy/pages/design/index.vue'; -import createRouter from '~/design_management_legacy/router'; -import { - ROOT_ROUTE_NAME, - DESIGNS_ROUTE_NAME, - DESIGN_ROUTE_NAME, -} from '~/design_management_legacy/router/constants'; -import '~/commons/bootstrap'; - -function factory(routeArg) { - const localVue = createLocalVue(); - localVue.use(VueRouter); - - window.gon = { sprite_icons: '' }; - - const router = createRouter('/'); - if (routeArg !== undefined) { - router.push(routeArg); - } - - return mount(App, { - localVue, - router, - mocks: { - $apollo: { - queries: { - designs: { loading: true }, - design: { loading: true }, - permissions: { loading: true }, - }, - mutate: jest.fn(), - }, - }, - }); -} - -jest.mock('mousetrap', () => ({ - bind: jest.fn(), - unbind: jest.fn(), -})); - -describe('Design management router', () => { - afterEach(() => { - window.location.hash = ''; - }); - - describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => { - it('pushes home component', () => { - const wrapper = factory(routeArg); - - expect(wrapper.find(Designs).exists()).toBe(true); - }); - }); - - describe.each([['/designs'], [{ name: DESIGNS_ROUTE_NAME }]])('designs route', routeArg => { - it('pushes designs root component', () => { - const wrapper = factory(routeArg); - - expect(wrapper.find(Designs).exists()).toBe(true); - }); - }); - - describe.each([['/designs/1'], [{ name: DESIGN_ROUTE_NAME, params: { id: '1' } }]])( - 'designs detail route', - routeArg => { - it('pushes designs detail component', () => { - const wrapper = factory(routeArg); - - return nextTick().then(() => { - const detail = wrapper.find(DesignDetail); - expect(detail.exists()).toBe(true); - expect(detail.props('id')).toEqual('1'); - }); - }); - }, - ); -}); diff --git a/spec/frontend/design_management_legacy/utils/cache_update_spec.js b/spec/frontend/design_management_legacy/utils/cache_update_spec.js deleted file mode 100644 index dce91b5e59b..00000000000 --- a/spec/frontend/design_management_legacy/utils/cache_update_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { InMemoryCache } from 'apollo-cache-inmemory'; -import { - updateStoreAfterDesignsDelete, - updateStoreAfterAddDiscussionComment, - updateStoreAfterAddImageDiffNote, - updateStoreAfterUploadDesign, - updateStoreAfterUpdateImageDiffNote, -} from '~/design_management_legacy/utils/cache_update'; -import { - designDeletionError, - ADD_DISCUSSION_COMMENT_ERROR, - ADD_IMAGE_DIFF_NOTE_ERROR, - UPDATE_IMAGE_DIFF_NOTE_ERROR, -} from '~/design_management_legacy/utils/error_messages'; -import design from '../mock_data/design'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; - -jest.mock('~/flash.js'); - -describe('Design Management cache update', () => { - const mockErrors = ['code red!']; - - let mockStore; - - beforeEach(() => { - mockStore = new InMemoryCache(); - }); - - describe('error handling', () => { - it.each` - fnName | subject | errorMessage | extraArgs - ${'updateStoreAfterDesignsDelete'} | ${updateStoreAfterDesignsDelete} | ${designDeletionError({ singular: true })} | ${[[design]]} - ${'updateStoreAfterAddDiscussionComment'} | ${updateStoreAfterAddDiscussionComment} | ${ADD_DISCUSSION_COMMENT_ERROR} | ${[]} - ${'updateStoreAfterAddImageDiffNote'} | ${updateStoreAfterAddImageDiffNote} | ${ADD_IMAGE_DIFF_NOTE_ERROR} | ${[]} - ${'updateStoreAfterUploadDesign'} | ${updateStoreAfterUploadDesign} | ${mockErrors[0]} | ${[]} - ${'updateStoreAfterUpdateImageDiffNote'} | ${updateStoreAfterUpdateImageDiffNote} | ${UPDATE_IMAGE_DIFF_NOTE_ERROR} | ${[]} - `('$fnName handles errors in response', ({ subject, extraArgs, errorMessage }) => { - expect(createFlash).not.toHaveBeenCalled(); - expect(() => subject(mockStore, { errors: mockErrors }, {}, ...extraArgs)).toThrow(); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith(errorMessage); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js b/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js deleted file mode 100644 index 97e85a24a35..00000000000 --- a/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js +++ /dev/null @@ -1,176 +0,0 @@ -import { - extractCurrentDiscussion, - extractDiscussions, - findVersionId, - designUploadOptimisticResponse, - updateImageDiffNoteOptimisticResponse, - isValidDesignFile, - extractDesign, -} from '~/design_management_legacy/utils/design_management_utils'; -import mockResponseNoDesigns from '../mock_data/no_designs'; -import mockResponseWithDesigns from '../mock_data/designs'; -import mockDesign from '../mock_data/design'; - -jest.mock('lodash/uniqueId', () => () => 1); - -describe('extractCurrentDiscussion', () => { - let discussions; - - beforeEach(() => { - discussions = { - nodes: [ - { id: 101, payload: 'w' }, - { id: 102, payload: 'x' }, - { id: 103, payload: 'y' }, - { id: 104, payload: 'z' }, - ], - }; - }); - - it('finds the relevant discussion if it exists', () => { - const id = 103; - expect(extractCurrentDiscussion(discussions, id)).toEqual({ id, payload: 'y' }); - }); - - it('returns null if the relevant discussion does not exist', () => { - expect(extractCurrentDiscussion(discussions, 0)).not.toBeDefined(); - }); -}); - -describe('extractDiscussions', () => { - let discussions; - - beforeEach(() => { - discussions = { - nodes: [ - { id: 1, notes: { nodes: ['a'] } }, - { id: 2, notes: { nodes: ['b'] } }, - { id: 3, notes: { nodes: ['c'] } }, - { id: 4, notes: { nodes: ['d'] } }, - ], - }; - }); - - it('discards the edges.node artifacts of GraphQL', () => { - expect(extractDiscussions(discussions)).toEqual([ - { id: 1, notes: ['a'], index: 1 }, - { id: 2, notes: ['b'], index: 2 }, - { id: 3, notes: ['c'], index: 3 }, - { id: 4, notes: ['d'], index: 4 }, - ]); - }); -}); - -describe('version parser', () => { - it('correctly extracts version ID from a valid version string', () => { - const testVersionId = '123'; - const testVersionString = `gid://gitlab/DesignManagement::Version/${testVersionId}`; - - expect(findVersionId(testVersionString)).toEqual(testVersionId); - }); - - it('fails to extract version ID from an invalid version string', () => { - const testInvalidVersionString = `gid://gitlab/DesignManagement::Version`; - - expect(findVersionId(testInvalidVersionString)).toBeUndefined(); - }); -}); - -describe('optimistic responses', () => { - it('correctly generated for designManagementUpload', () => { - const expectedResponse = { - __typename: 'Mutation', - designManagementUpload: { - __typename: 'DesignManagementUploadPayload', - designs: [ - { - __typename: 'Design', - id: -1, - image: '', - imageV432x230: '', - filename: 'test', - fullPath: '', - notesCount: 0, - event: 'NONE', - diffRefs: { __typename: 'DiffRefs', baseSha: '', startSha: '', headSha: '' }, - discussions: { __typename: 'DesignDiscussion', nodes: [] }, - versions: { - __typename: 'DesignVersionConnection', - edges: { - __typename: 'DesignVersionEdge', - node: { __typename: 'DesignVersion', id: -1, sha: -1 }, - }, - }, - }, - ], - errors: [], - skippedDesigns: [], - }, - }; - expect(designUploadOptimisticResponse([{ name: 'test' }])).toEqual(expectedResponse); - }); - - it('correctly generated for updateImageDiffNoteOptimisticResponse', () => { - const mockNote = { - id: 'test-note-id', - }; - - const mockPosition = { - x: 10, - y: 10, - width: 10, - height: 10, - }; - - const expectedResponse = { - __typename: 'Mutation', - updateImageDiffNote: { - __typename: 'UpdateImageDiffNotePayload', - note: { - ...mockNote, - position: mockPosition, - }, - errors: [], - }, - }; - expect(updateImageDiffNoteOptimisticResponse(mockNote, { position: mockPosition })).toEqual( - expectedResponse, - ); - }); -}); - -describe('isValidDesignFile', () => { - // test every filetype that Design Management supports - // https://docs.gitlab.com/ee/user/project/issues/design_management.html#limitations - it.each` - mimetype | isValid - ${'image/svg'} | ${true} - ${'image/png'} | ${true} - ${'image/jpg'} | ${true} - ${'image/jpeg'} | ${true} - ${'image/gif'} | ${true} - ${'image/bmp'} | ${true} - ${'image/tiff'} | ${true} - ${'image/ico'} | ${true} - ${'image/svg'} | ${true} - ${'video/mpeg'} | ${false} - ${'audio/midi'} | ${false} - ${'application/octet-stream'} | ${false} - `('returns $isValid for file type $mimetype', ({ mimetype, isValid }) => { - expect(isValidDesignFile({ type: mimetype })).toBe(isValid); - }); -}); - -describe('extractDesign', () => { - describe('with no designs', () => { - it('returns undefined', () => { - expect(extractDesign(mockResponseNoDesigns)).toBeUndefined(); - }); - }); - - describe('with designs', () => { - it('returns the first design available', () => { - expect(extractDesign(mockResponseWithDesigns)).toEqual(mockDesign); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/utils/error_messages_spec.js b/spec/frontend/design_management_legacy/utils/error_messages_spec.js deleted file mode 100644 index 489ac23da4e..00000000000 --- a/spec/frontend/design_management_legacy/utils/error_messages_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { - designDeletionError, - designUploadSkippedWarning, -} from '~/design_management_legacy/utils/error_messages'; - -const mockFilenames = n => - Array(n) - .fill(0) - .map((_, i) => ({ filename: `${i + 1}.jpg` })); - -describe('Error message', () => { - describe('designDeletionError', () => { - const singularMsg = 'Could not delete a design. Please try again.'; - const pluralMsg = 'Could not delete designs. Please try again.'; - - describe('when [singular=true]', () => { - it.each([[undefined], [true]])('uses singular grammar', singularOption => { - expect(designDeletionError({ singular: singularOption })).toEqual(singularMsg); - }); - }); - - describe('when [singular=false]', () => { - it('uses plural grammar', () => { - expect(designDeletionError({ singular: false })).toEqual(pluralMsg); - }); - }); - }); - - describe.each([ - [[], [], null], - [mockFilenames(1), mockFilenames(1), 'Upload skipped. 1.jpg did not change.'], - [ - mockFilenames(2), - mockFilenames(2), - 'Upload skipped. The designs you tried uploading did not change.', - ], - [ - mockFilenames(2), - mockFilenames(1), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg.', - ], - [ - mockFilenames(6), - mockFilenames(5), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg.', - ], - [ - mockFilenames(7), - mockFilenames(6), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 1 more.', - ], - [ - mockFilenames(8), - mockFilenames(7), - 'Upload skipped. Some of the designs you tried uploading did not change: 1.jpg, 2.jpg, 3.jpg, 4.jpg, 5.jpg, and 2 more.', - ], - ])('designUploadSkippedWarning', (uploadedFiles, skippedFiles, expected) => { - it('returns expected warning message', () => { - expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected); - }); - }); -}); diff --git a/spec/frontend/design_management_legacy/utils/tracking_spec.js b/spec/frontend/design_management_legacy/utils/tracking_spec.js deleted file mode 100644 index a59cf80c906..00000000000 --- a/spec/frontend/design_management_legacy/utils/tracking_spec.js +++ /dev/null @@ -1,59 +0,0 @@ -import { mockTracking } from 'helpers/tracking_helper'; -import { trackDesignDetailView } from '~/design_management_legacy/utils/tracking'; - -function getTrackingSpy(key) { - return mockTracking(key, undefined, jest.spyOn); -} - -describe('Tracking Events', () => { - describe('trackDesignDetailView', () => { - const eventKey = 'projects:issues:design'; - const eventName = 'view_design'; - - it('trackDesignDetailView fires a tracking event when called', () => { - const trackingSpy = getTrackingSpy(eventKey); - - trackDesignDetailView(); - - expect(trackingSpy).toHaveBeenCalledWith( - eventKey, - eventName, - expect.objectContaining({ - label: eventName, - context: { - schema: expect.any(String), - data: { - 'design-version-number': 1, - 'design-is-current-version': false, - 'internal-object-referrer': '', - 'design-collection-owner': '', - }, - }, - }), - ); - }); - - it('trackDesignDetailView allows to customize the value payload', () => { - const trackingSpy = getTrackingSpy(eventKey); - - trackDesignDetailView('from-a-test', 'test', 100, true); - - expect(trackingSpy).toHaveBeenCalledWith( - eventKey, - eventName, - expect.objectContaining({ - label: eventName, - context: { - schema: expect.any(String), - data: { - 'design-version-number': 100, - 'design-is-current-version': true, - 'internal-object-referrer': 'from-a-test', - 'design-collection-owner': 'test', - }, - }, - }), - ); - }); - }); -}); diff --git a/spec/frontend/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index ac046ddc203..cd3a6aa0e28 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'spec/test_constants'; import Mousetrap from 'mousetrap'; @@ -9,6 +9,7 @@ import NoChanges from '~/diffs/components/no_changes.vue'; import DiffFile from '~/diffs/components/diff_file.vue'; import CompareVersions from '~/diffs/components/compare_versions.vue'; import HiddenFilesWarning from '~/diffs/components/hidden_files_warning.vue'; +import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; import CommitWidget from '~/diffs/components/commit_widget.vue'; import TreeList from '~/diffs/components/tree_list.vue'; import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; @@ -22,6 +23,10 @@ const TEST_ENDPOINT = `${TEST_HOST}/diff/endpoint`; const COMMIT_URL = '[BASE URL]/OLD'; const UPDATED_COMMIT_URL = '[BASE URL]/NEW'; +function getCollapsedFilesWarning(wrapper) { + return wrapper.find(CollapsedFilesWarning); +} + describe('diffs/components/app', () => { const oldMrTabs = window.mrTabs; let store; @@ -108,7 +113,6 @@ describe('diffs/components/app', () => { }; jest.spyOn(window, 'requestIdleCallback').mockImplementation(fn => fn()); createComponent(); - jest.spyOn(wrapper.vm, 'fetchDiffFiles').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchDiffFilesMeta').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchDiffFilesBatch').mockImplementation(fetchResolver); jest.spyOn(wrapper.vm, 'fetchCoverageFiles').mockImplementation(fetchResolver); @@ -139,37 +143,21 @@ describe('diffs/components/app', () => { parallel_diff_lines: ['line'], }; - function expectFetchToOccur({ - vueInstance, - done = () => {}, - batch = false, - existingFiles = 1, - } = {}) { + function expectFetchToOccur({ vueInstance, done = () => {}, existingFiles = 1 } = {}) { vueInstance.$nextTick(() => { expect(vueInstance.diffFiles.length).toEqual(existingFiles); - - if (!batch) { - expect(vueInstance.fetchDiffFiles).toHaveBeenCalled(); - expect(vueInstance.fetchDiffFilesBatch).not.toHaveBeenCalled(); - } else { - expect(vueInstance.fetchDiffFiles).not.toHaveBeenCalled(); - expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled(); - } + expect(vueInstance.fetchDiffFilesBatch).toHaveBeenCalled(); done(); }); } - beforeEach(() => { - wrapper.vm.glFeatures.singleMrDiffView = true; - }); - it('fetches diffs if it has none', done => { wrapper.vm.isLatestVersion = () => false; store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - expectFetchToOccur({ vueInstance: wrapper.vm, batch: false, existingFiles: 0, done }); + expectFetchToOccur({ vueInstance: wrapper.vm, existingFiles: 0, done }); }); it('fetches diffs if it has both view styles, but no lines in either', done => { @@ -200,89 +188,46 @@ describe('diffs/components/app', () => { }); it('fetches batch diffs if it has none', done => { - wrapper.vm.glFeatures.diffsBatchLoad = true; - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, existingFiles: 0, done }); + expectFetchToOccur({ vueInstance: wrapper.vm, existingFiles: 0, done }); }); it('fetches batch diffs if it has both view styles, but no lines in either', done => { - wrapper.vm.glFeatures.diffsBatchLoad = true; - store.state.diffs.diffFiles.push(noLinesDiff); store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done }); + expectFetchToOccur({ vueInstance: wrapper.vm, done }); }); it('fetches batch diffs if it only has inline view style', done => { - wrapper.vm.glFeatures.diffsBatchLoad = true; - store.state.diffs.diffFiles.push(inlineLinesDiff); store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done }); + expectFetchToOccur({ vueInstance: wrapper.vm, done }); }); it('fetches batch diffs if it only has parallel view style', done => { - wrapper.vm.glFeatures.diffsBatchLoad = true; - store.state.diffs.diffFiles.push(parallelLinesDiff); store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - expectFetchToOccur({ vueInstance: wrapper.vm, batch: true, done }); - }); - - it('does not fetch diffs if it has already fetched both styles of diff', () => { - wrapper.vm.glFeatures.diffsBatchLoad = false; - - store.state.diffs.diffFiles.push(fullDiff); - store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); - - expect(wrapper.vm.diffFiles.length).toEqual(1); - expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); - expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); + expectFetchToOccur({ vueInstance: wrapper.vm, done }); }); it('does not fetch batch diffs if it has already fetched both styles of diff', () => { - wrapper.vm.glFeatures.diffsBatchLoad = true; - store.state.diffs.diffFiles.push(fullDiff); store.state.diffs.diffViewType = getOppositeViewType(wrapper.vm.diffViewType); expect(wrapper.vm.diffFiles.length).toEqual(1); - expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); }); }); - it('calls fetchDiffFiles if diffsBatchLoad is not enabled', done => { - expect(wrapper.vm.diffFilesLength).toEqual(0); - wrapper.vm.glFeatures.diffsBatchLoad = false; - wrapper.vm.fetchData(false); - - expect(wrapper.vm.fetchDiffFiles).toHaveBeenCalled(); - setImmediate(() => { - expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); - expect(wrapper.vm.fetchDiffFilesMeta).not.toHaveBeenCalled(); - expect(wrapper.vm.fetchDiffFilesBatch).not.toHaveBeenCalled(); - expect(wrapper.vm.fetchCoverageFiles).toHaveBeenCalled(); - expect(wrapper.vm.unwatchDiscussions).toHaveBeenCalled(); - expect(wrapper.vm.diffFilesLength).toEqual(100); - expect(wrapper.vm.unwatchRetrievingBatches).toHaveBeenCalled(); - - done(); - }); - }); - it('calls batch methods if diffsBatchLoad is enabled, and not latest version', done => { expect(wrapper.vm.diffFilesLength).toEqual(0); - wrapper.vm.glFeatures.diffsBatchLoad = true; wrapper.vm.isLatestVersion = () => false; wrapper.vm.fetchData(false); - expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); setImmediate(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); @@ -297,10 +242,8 @@ describe('diffs/components/app', () => { it('calls batch methods if diffsBatchLoad is enabled, and latest version', done => { expect(wrapper.vm.diffFilesLength).toEqual(0); - wrapper.vm.glFeatures.diffsBatchLoad = true; wrapper.vm.fetchData(false); - expect(wrapper.vm.fetchDiffFiles).not.toHaveBeenCalled(); setImmediate(() => { expect(wrapper.vm.startRenderDiffsQueue).toHaveBeenCalled(); expect(wrapper.vm.fetchDiffFilesMeta).toHaveBeenCalled(); @@ -320,7 +263,7 @@ describe('diffs/components/app', () => { state.diffs.isParallelView = false; }); - expect(wrapper.contains('.container-limited.limit-container-width')).toBe(true); + expect(wrapper.find('.container-limited.limit-container-width').exists()).toBe(true); }); it('does not add container-limiting classes when showFileTree is false with inline diffs', () => { @@ -329,7 +272,7 @@ describe('diffs/components/app', () => { state.diffs.isParallelView = false; }); - expect(wrapper.contains('.container-limited.limit-container-width')).toBe(false); + expect(wrapper.find('.container-limited.limit-container-width').exists()).toBe(false); }); it('does not add container-limiting classes when isFluidLayout', () => { @@ -337,7 +280,7 @@ describe('diffs/components/app', () => { state.diffs.isParallelView = false; }); - expect(wrapper.contains('.container-limited.limit-container-width')).toBe(false); + expect(wrapper.find('.container-limited.limit-container-width').exists()).toBe(false); }); it('displays loading icon on loading', () => { @@ -345,7 +288,7 @@ describe('diffs/components/app', () => { state.diffs.isLoading = true; }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); it('displays loading icon on batch loading', () => { @@ -353,20 +296,20 @@ describe('diffs/components/app', () => { state.diffs.isBatchLoading = true; }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); it('displays diffs container when not loading', () => { createComponent(); - expect(wrapper.contains(GlLoadingIcon)).toBe(false); - expect(wrapper.contains('#diffs')).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('#diffs').exists()).toBe(true); }); it('does not show commit info', () => { createComponent(); - expect(wrapper.contains('.blob-commit-info')).toBe(false); + expect(wrapper.find('.blob-commit-info').exists()).toBe(false); }); describe('row highlighting', () => { @@ -442,7 +385,7 @@ describe('diffs/components/app', () => { it('renders empty state when no diff files exist', () => { createComponent(); - expect(wrapper.contains(NoChanges)).toBe(true); + expect(wrapper.find(NoChanges).exists()).toBe(true); }); it('does not render empty state when diff files exist', () => { @@ -452,7 +395,7 @@ describe('diffs/components/app', () => { }); }); - expect(wrapper.contains(NoChanges)).toBe(false); + expect(wrapper.find(NoChanges).exists()).toBe(false); expect(wrapper.findAll(DiffFile).length).toBe(1); }); @@ -462,7 +405,7 @@ describe('diffs/components/app', () => { state.diffs.mergeRequestDiff = mergeRequestDiff; }); - expect(wrapper.contains(NoChanges)).toBe(false); + expect(wrapper.find(NoChanges).exists()).toBe(false); }); }); @@ -722,7 +665,7 @@ describe('diffs/components/app', () => { state.diffs.mergeRequestDiff = mergeRequestDiff; }); - expect(wrapper.contains(CompareVersions)).toBe(true); + expect(wrapper.find(CompareVersions).exists()).toBe(true); expect(wrapper.find(CompareVersions).props()).toEqual( expect.objectContaining({ mergeRequestDiffs: diffsMockData, @@ -730,24 +673,51 @@ describe('diffs/components/app', () => { ); }); - it('should render hidden files warning if render overflow warning is present', () => { - createComponent({}, ({ state }) => { - state.diffs.renderOverflowWarning = true; - state.diffs.realSize = '5'; - state.diffs.plainDiffPath = 'plain diff path'; - state.diffs.emailPatchPath = 'email patch path'; - state.diffs.size = 1; + describe('warnings', () => { + describe('hidden files', () => { + it('should render hidden files warning if render overflow warning is present', () => { + createComponent({}, ({ state }) => { + state.diffs.renderOverflowWarning = true; + state.diffs.realSize = '5'; + state.diffs.plainDiffPath = 'plain diff path'; + state.diffs.emailPatchPath = 'email patch path'; + state.diffs.size = 1; + }); + + expect(wrapper.find(HiddenFilesWarning).exists()).toBe(true); + expect(wrapper.find(HiddenFilesWarning).props()).toEqual( + expect.objectContaining({ + total: '5', + plainDiffPath: 'plain diff path', + emailPatchPath: 'email patch path', + visible: 1, + }), + ); + }); }); - expect(wrapper.contains(HiddenFilesWarning)).toBe(true); - expect(wrapper.find(HiddenFilesWarning).props()).toEqual( - expect.objectContaining({ - total: '5', - plainDiffPath: 'plain diff path', - emailPatchPath: 'email patch path', - visible: 1, - }), - ); + describe('collapsed files', () => { + it('should render the collapsed files warning if there are any collapsed files', () => { + createComponent({}, ({ state }) => { + state.diffs.diffFiles = [{ viewer: { collapsed: true } }]; + }); + + expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true); + }); + + it('should not render the collapsed files warning if the user has dismissed the alert already', async () => { + createComponent({}, ({ state }) => { + state.diffs.diffFiles = [{ viewer: { collapsed: true } }]; + }); + + expect(getCollapsedFilesWarning(wrapper).exists()).toBe(true); + + wrapper.vm.collapsedWarningDismissed = true; + await wrapper.vm.$nextTick(); + + expect(getCollapsedFilesWarning(wrapper).exists()).toBe(false); + }); + }); }); it('should display commit widget if store has a commit', () => { @@ -757,7 +727,7 @@ describe('diffs/components/app', () => { }; }); - expect(wrapper.contains(CommitWidget)).toBe(true); + expect(wrapper.find(CommitWidget).exists()).toBe(true); }); it('should display diff file if there are diff files', () => { @@ -765,7 +735,7 @@ describe('diffs/components/app', () => { state.diffs.diffFiles.push({ sha: '123' }); }); - expect(wrapper.contains(DiffFile)).toBe(true); + expect(wrapper.find(DiffFile).exists()).toBe(true); }); it('should render tree list', () => { @@ -843,13 +813,16 @@ describe('diffs/components/app', () => { }); describe('pagination', () => { + const fileByFileNav = () => wrapper.find('[data-testid="file-by-file-navigation"]'); + const paginator = () => fileByFileNav().find(GlPagination); + it('sets previous button as disabled', () => { createComponent({ viewDiffsFileByFile: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); }); - expect(wrapper.find('[data-testid="singleFilePrevious"]').props('disabled')).toBe(true); - expect(wrapper.find('[data-testid="singleFileNext"]').props('disabled')).toBe(false); + expect(paginator().attributes('prevpage')).toBe(undefined); + expect(paginator().attributes('nextpage')).toBe('2'); }); it('sets next button as disabled', () => { @@ -858,17 +831,26 @@ describe('diffs/components/app', () => { state.diffs.currentDiffFileId = '312'; }); - expect(wrapper.find('[data-testid="singleFilePrevious"]').props('disabled')).toBe(false); - expect(wrapper.find('[data-testid="singleFileNext"]').props('disabled')).toBe(true); + expect(paginator().attributes('prevpage')).toBe('1'); + expect(paginator().attributes('nextpage')).toBe(undefined); + }); + + it("doesn't display when there's fewer than 2 files", () => { + createComponent({ viewDiffsFileByFile: true }, ({ state }) => { + state.diffs.diffFiles.push({ file_hash: '123' }); + state.diffs.currentDiffFileId = '123'; + }); + + expect(fileByFileNav().exists()).toBe(false); }); it.each` - currentDiffFileId | button | index - ${'123'} | ${'singleFileNext'} | ${1} - ${'312'} | ${'singleFilePrevious'} | ${0} + currentDiffFileId | targetFile + ${'123'} | ${2} + ${'312'} | ${1} `( - 'it calls navigateToDiffFileIndex with $index when $button is clicked', - ({ currentDiffFileId, button, index }) => { + 'it calls navigateToDiffFileIndex with $index when $link is clicked', + async ({ currentDiffFileId, targetFile }) => { createComponent({ viewDiffsFileByFile: true }, ({ state }) => { state.diffs.diffFiles.push({ file_hash: '123' }, { file_hash: '312' }); state.diffs.currentDiffFileId = currentDiffFileId; @@ -876,11 +858,11 @@ describe('diffs/components/app', () => { jest.spyOn(wrapper.vm, 'navigateToDiffFileIndex'); - wrapper.find(`[data-testid="${button}"]`).vm.$emit('click'); + paginator().vm.$emit('input', targetFile); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(index); - }); + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.navigateToDiffFileIndex).toHaveBeenCalledWith(targetFile - 1); }, ); }); diff --git a/spec/frontend/diffs/components/collapsed_files_warning_spec.js b/spec/frontend/diffs/components/collapsed_files_warning_spec.js new file mode 100644 index 00000000000..670eab5472f --- /dev/null +++ b/spec/frontend/diffs/components/collapsed_files_warning_spec.js @@ -0,0 +1,88 @@ +import Vuex from 'vuex'; +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/diffs/store/modules'; +import CollapsedFilesWarning from '~/diffs/components/collapsed_files_warning.vue'; +import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants'; + +const propsData = { + limited: true, + mergeable: true, + resolutionPath: 'a-path', +}; +const limitedClasses = CENTERED_LIMITED_CONTAINER_CLASSES.split(' '); + +describe('CollapsedFilesWarning', () => { + const localVue = createLocalVue(); + let store; + let wrapper; + + localVue.use(Vuex); + + const getAlertActionButton = () => + wrapper.find(CollapsedFilesWarning).find('button.gl-alert-action:first-child'); + const getAlertCloseButton = () => wrapper.find(CollapsedFilesWarning).find('button'); + + const createComponent = (props = {}, { full } = { full: false }) => { + const mounter = full ? mount : shallowMount; + store = new Vuex.Store({ + modules: { + diffs: createStore(), + }, + }); + + wrapper = mounter(CollapsedFilesWarning, { + propsData: { ...propsData, ...props }, + localVue, + store, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + limited | containerClasses + ${true} | ${limitedClasses} + ${false} | ${[]} + `( + 'has the correct container classes when limited is $limited', + ({ limited, containerClasses }) => { + createComponent({ limited }); + + expect(wrapper.classes()).toEqual(containerClasses); + }, + ); + + it.each` + present | dismissed + ${false} | ${true} + ${true} | ${false} + `('toggles the alert when dismissed is $dismissed', ({ present, dismissed }) => { + createComponent({ dismissed }); + + expect(wrapper.find('[data-testid="root"]').exists()).toBe(present); + }); + + it('dismisses the component when the alert "x" is clicked', async () => { + createComponent({}, { full: true }); + + expect(wrapper.find('[data-testid="root"]').exists()).toBe(true); + + getAlertCloseButton().element.click(); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('[data-testid="root"]').exists()).toBe(false); + }); + + it('triggers the expandAllFiles action when the alert action button is clicked', () => { + createComponent({}, { full: true }); + + jest.spyOn(wrapper.vm.$store, 'dispatch').mockReturnValue(undefined); + + getAlertActionButton().vm.$emit('click'); + + expect(wrapper.vm.$store.dispatch).toHaveBeenCalledWith('diffs/expandAllFiles', undefined); + }); +}); diff --git a/spec/frontend/diffs/components/commit_item_spec.js b/spec/frontend/diffs/components/commit_item_spec.js index 0df951d43a7..c48445790f7 100644 --- a/spec/frontend/diffs/components/commit_item_spec.js +++ b/spec/frontend/diffs/components/commit_item_spec.js @@ -24,8 +24,7 @@ describe('diffs/components/commit_item', () => { const getTitleElement = () => wrapper.find('.commit-row-message.item-title'); const getDescElement = () => wrapper.find('pre.commit-row-description'); - const getDescExpandElement = () => - wrapper.find('.commit-content .text-expander.js-toggle-button'); + const getDescExpandElement = () => wrapper.find('.commit-content .js-toggle-button'); const getShaElement = () => wrapper.find('.commit-sha-group'); const getAvatarElement = () => wrapper.find('.user-avatar-link'); const getCommitterElement = () => wrapper.find('.committer'); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 7fdbc791589..b3dfc71260c 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -2,7 +2,6 @@ import { trimText } from 'helpers/text_helper'; import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import CompareVersionsComponent from '~/diffs/components/compare_versions.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import { createStore } from '~/mr_notes/stores'; import diffsMockData from '../mock_data/merge_request_diffs'; import getDiffWithCommit from '../mock_data/diff_with_commit'; @@ -51,7 +50,7 @@ describe('CompareVersions', () => { expect(treeListBtn.exists()).toBe(true); expect(treeListBtn.attributes('title')).toBe('Hide file browser'); - expect(treeListBtn.find(Icon).props('name')).toBe('file-tree'); + expect(treeListBtn.props('icon')).toBe('file-tree'); }); it('should render comparison dropdowns with correct values', () => { diff --git a/spec/frontend/diffs/components/diff_content_spec.js b/spec/frontend/diffs/components/diff_content_spec.js index b78895f9e55..6d0120d888e 100644 --- a/spec/frontend/diffs/components/diff_content_spec.js +++ b/spec/frontend/diffs/components/diff_content_spec.js @@ -177,23 +177,19 @@ describe('DiffContent', () => { }); wrapper.find(NoteForm).vm.$emit('handleFormUpdate', noteStub); - expect(saveDiffDiscussionMock).toHaveBeenCalledWith( - expect.any(Object), - { - note: noteStub, - formData: { - noteableData: expect.any(Object), - diffFile: currentDiffFile, - positionType: IMAGE_DIFF_POSITION_TYPE, - x: undefined, - y: undefined, - width: undefined, - height: undefined, - noteableType: undefined, - }, + expect(saveDiffDiscussionMock).toHaveBeenCalledWith(expect.any(Object), { + note: noteStub, + formData: { + noteableData: expect.any(Object), + diffFile: currentDiffFile, + positionType: IMAGE_DIFF_POSITION_TYPE, + x: undefined, + y: undefined, + width: undefined, + height: undefined, + noteableType: undefined, }, - undefined, - ); + }); }); }); }); diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js index 83becc7a20a..96b76183cee 100644 --- a/spec/frontend/diffs/components/diff_discussions_spec.js +++ b/spec/frontend/diffs/components/diff_discussions_spec.js @@ -1,9 +1,9 @@ import { mount, createLocalVue } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import DiffDiscussions from '~/diffs/components/diff_discussions.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import { createStore } from '~/mr_notes/stores'; import '~/behaviors/markdown/render_gfm'; import discussionsMockData from '../mock_data/diff_discussions'; @@ -51,7 +51,7 @@ describe('DiffDiscussions', () => { const diffNotesToggle = findDiffNotesToggle(); expect(diffNotesToggle.exists()).toBe(true); - expect(diffNotesToggle.find(Icon).exists()).toBe(true); + expect(diffNotesToggle.find(GlIcon).exists()).toBe(true); expect(diffNotesToggle.classes('diff-notes-collapse')).toBe(true); }); diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index b8aca4ad86b..81e08f09f62 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -10,7 +10,6 @@ import diffFileMockData from '../mock_data/diff_file'; const EXPAND_UP_CLASS = '.js-unfold'; const EXPAND_DOWN_CLASS = '.js-unfold-down'; -const LINE_TO_USE = 5; const lineSources = { [INLINE_DIFF_VIEW_TYPE]: 'highlighted_diff_lines', [PARALLEL_DIFF_VIEW_TYPE]: 'parallel_diff_lines', @@ -66,7 +65,7 @@ describe('DiffExpansionCell', () => { beforeEach(() => { mockFile = cloneDeep(diffFileMockData); - mockLine = getLine(mockFile, INLINE_DIFF_VIEW_TYPE, LINE_TO_USE); + mockLine = getLine(mockFile, INLINE_DIFF_VIEW_TYPE, 8); store = createStore(); store.state.diffs.diffFiles = [mockFile]; jest.spyOn(store, 'dispatch').mockReturnValue(Promise.resolve()); @@ -88,7 +87,7 @@ describe('DiffExpansionCell', () => { const findExpandUp = () => vm.$el.querySelector(EXPAND_UP_CLASS); const findExpandDown = () => vm.$el.querySelector(EXPAND_DOWN_CLASS); - const findExpandAll = () => getByText(vm.$el, 'Show unchanged lines'); + const findExpandAll = () => getByText(vm.$el, 'Show all unchanged lines'); describe('top row', () => { it('should have "expand up" and "show all" option', () => { @@ -126,12 +125,12 @@ describe('DiffExpansionCell', () => { describe('any row', () => { [ - { diffViewType: INLINE_DIFF_VIEW_TYPE, file: { parallel_diff_lines: [] } }, - { diffViewType: PARALLEL_DIFF_VIEW_TYPE, file: { highlighted_diff_lines: [] } }, - ].forEach(({ diffViewType, file }) => { + { diffViewType: INLINE_DIFF_VIEW_TYPE, lineIndex: 8, file: { parallel_diff_lines: [] } }, + { diffViewType: PARALLEL_DIFF_VIEW_TYPE, lineIndex: 7, file: { highlighted_diff_lines: [] } }, + ].forEach(({ diffViewType, file, lineIndex }) => { describe(`with diffViewType (${diffViewType})`, () => { beforeEach(() => { - mockLine = getLine(mockFile, diffViewType, LINE_TO_USE); + mockLine = getLine(mockFile, diffViewType, lineIndex); store.state.diffs.diffFiles = [{ ...mockFile, ...file }]; store.state.diffs.diffViewType = diffViewType; }); @@ -189,10 +188,10 @@ describe('DiffExpansionCell', () => { }); it('on expand down clicked, dispatch loadMoreLines', () => { - mockFile[lineSources[diffViewType]][LINE_TO_USE + 1] = cloneDeep( - mockFile[lineSources[diffViewType]][LINE_TO_USE], + mockFile[lineSources[diffViewType]][lineIndex + 1] = cloneDeep( + mockFile[lineSources[diffViewType]][lineIndex], ); - const nextLine = getLine(mockFile, diffViewType, LINE_TO_USE + 1); + const nextLine = getLine(mockFile, diffViewType, lineIndex + 1); nextLine.meta_data.old_pos = 300; nextLine.meta_data.new_pos = 300; diff --git a/spec/frontend/diffs/components/diff_file_header_spec.js b/spec/frontend/diffs/components/diff_file_header_spec.js index 671dced080c..a0cad32b9fb 100644 --- a/spec/frontend/diffs/components/diff_file_header_spec.js +++ b/spec/frontend/diffs/components/diff_file_header_spec.js @@ -1,9 +1,10 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; +import { GlIcon } from '@gitlab/ui'; +import { cloneDeep } from 'lodash'; import DiffFileHeader from '~/diffs/components/diff_file_header.vue'; import EditButton from '~/diffs/components/edit_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import diffDiscussionsMockData from '../mock_data/diff_discussions'; import { truncateSha } from '~/lib/utils/text_utility'; import { diffViewerModes } from '~/ide/constants'; @@ -26,12 +27,16 @@ const diffFile = Object.freeze( }), ); +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('DiffFileHeader component', () => { let wrapper; + let mockStoreConfig; const diffHasExpandedDiscussionsResultMock = jest.fn(); const diffHasDiscussionsResultMock = jest.fn(); - const mockStoreConfig = { + const defaultMockStoreConfig = { state: {}, modules: { diffs: { @@ -44,6 +49,7 @@ describe('DiffFileHeader component', () => { toggleFileDiscussions: jest.fn(), toggleFileDiscussionWrappers: jest.fn(), toggleFullDiff: jest.fn(), + toggleActiveFileByHash: jest.fn(), }, }, }, @@ -55,6 +61,8 @@ describe('DiffFileHeader component', () => { diffHasExpandedDiscussionsResultMock, ...Object.values(mockStoreConfig.modules.diffs.actions), ].forEach(mock => mock.mockReset()); + + wrapper.destroy(); }); const findHeader = () => wrapper.find({ ref: 'header' }); @@ -70,7 +78,7 @@ describe('DiffFileHeader component', () => { const findCollapseIcon = () => wrapper.find({ ref: 'collapseIcon' }); const findIconByName = iconName => { - const icons = wrapper.findAll(Icon).filter(w => w.props('name') === iconName); + const icons = wrapper.findAll(GlIcon).filter(w => w.props('name') === iconName); if (icons.length === 0) return icons; if (icons.length > 1) { throw new Error(`Multiple icons found for ${iconName}`); @@ -79,8 +87,7 @@ describe('DiffFileHeader component', () => { }; const createComponent = props => { - const localVue = createLocalVue(); - localVue.use(Vuex); + mockStoreConfig = cloneDeep(defaultMockStoreConfig); const store = new Vuex.Store(mockStoreConfig); wrapper = shallowMount(DiffFileHeader, { @@ -285,7 +292,7 @@ describe('DiffFileHeader component', () => { findToggleDiscussionsButton().vm.$emit('click'); expect( mockStoreConfig.modules.diffs.actions.toggleFileDiscussionWrappers, - ).toHaveBeenCalledWith(expect.any(Object), diffFile, undefined); + ).toHaveBeenCalledWith(expect.any(Object), diffFile); }); }); diff --git a/spec/frontend/diffs/components/diff_file_row_spec.js b/spec/frontend/diffs/components/diff_file_row_spec.js index afdd4bfb335..23adc8f9da4 100644 --- a/spec/frontend/diffs/components/diff_file_row_spec.js +++ b/spec/frontend/diffs/components/diff_file_row_spec.js @@ -7,9 +7,12 @@ import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; describe('Diff File Row component', () => { let wrapper; - const createComponent = (props = {}) => { + const createComponent = (props = {}, highlightCurrentDiffRow = false) => { wrapper = shallowMount(DiffFileRow, { propsData: { ...props }, + provide: { + glFeatures: { highlightCurrentDiffRow }, + }, }); }; @@ -56,6 +59,31 @@ describe('Diff File Row component', () => { ); }); + it.each` + features | fileType | isViewed | expected + ${{ highlightCurrentDiffRow: true }} | ${'blob'} | ${false} | ${'gl-font-weight-bold'} + ${{}} | ${'blob'} | ${true} | ${''} + ${{}} | ${'tree'} | ${false} | ${''} + ${{}} | ${'tree'} | ${true} | ${''} + `( + 'with (features="$features", fileType="$fileType", isViewed=$isViewed), sets fileClasses="$expected"', + ({ features, fileType, isViewed, expected }) => { + createComponent( + { + file: { + type: fileType, + fileHash: '#123456789', + }, + level: 0, + hideFileStats: false, + viewedFiles: isViewed ? { '#123456789': true } : {}, + }, + features.highlightCurrentDiffRow, + ); + expect(wrapper.find(FileRow).props('fileClasses')).toBe(expected); + }, + ); + describe('FileRowStats components', () => { it.each` type | hideFileStats | value | desc diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index ead8bd79cdb..79f0f6bc327 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -45,7 +45,7 @@ describe('DiffFile', () => { vm.$nextTick() .then(() => { - expect(el.querySelectorAll('.line_content').length).toBe(5); + expect(el.querySelectorAll('.line_content').length).toBe(8); expect(el.querySelectorAll('.js-line-expansion-content').length).toBe(1); triggerEvent('.btn-clipboard'); }) @@ -90,8 +90,8 @@ describe('DiffFile', () => { vm.isCollapsed = true; vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This diff is collapsed'); - expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); + expect(vm.$el.innerText).toContain('This file is collapsed.'); + expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy(); done(); }); @@ -102,8 +102,8 @@ describe('DiffFile', () => { vm.isCollapsed = true; vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This diff is collapsed'); - expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); + expect(vm.$el.innerText).toContain('This file is collapsed.'); + expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy(); done(); }); @@ -121,28 +121,8 @@ describe('DiffFile', () => { vm.isCollapsed = true; vm.$nextTick(() => { - expect(vm.$el.innerText).toContain('This diff is collapsed'); - expect(vm.$el.querySelectorAll('.js-click-to-expand').length).toEqual(1); - - done(); - }); - }); - - it('should auto-expand collapsed files when viewDiffsFileByFile is true', done => { - vm.$destroy(); - window.gon = { - features: { autoExpandCollapsedDiffs: true }, - }; - vm = createComponentWithStore(Vue.extend(DiffFileComponent), createStore(), { - file: JSON.parse(JSON.stringify(diffFileMockDataUnreadable)), - canCurrentUserFork: false, - viewDiffsFileByFile: true, - }).$mount(); - - vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This diff is collapsed'); - - window.gon = {}; + expect(vm.$el.innerText).toContain('This file is collapsed.'); + expect(vm.$el.querySelector('[data-testid="expandButton"]')).not.toBeFalsy(); done(); }); @@ -155,7 +135,7 @@ describe('DiffFile', () => { vm.file.viewer.name = diffViewerModes.renamed; vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + expect(vm.$el.innerText).not.toContain('This file is collapsed.'); done(); }); @@ -168,7 +148,7 @@ describe('DiffFile', () => { vm.file.viewer.name = diffViewerModes.mode_changed; vm.$nextTick(() => { - expect(vm.$el.innerText).not.toContain('This diff is collapsed'); + expect(vm.$el.innerText).not.toContain('This file is collapsed.'); done(); }); @@ -235,7 +215,7 @@ describe('DiffFile', () => { it('calls handleLoadCollapsedDiff if collapsed changed & file has no lines', done => { jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {}); - vm.file.highlighted_diff_lines = undefined; + vm.file.highlighted_diff_lines = []; vm.file.parallel_diff_lines = []; vm.isCollapsed = true; @@ -262,8 +242,8 @@ describe('DiffFile', () => { jest.spyOn(vm, 'handleLoadCollapsedDiff').mockImplementation(() => {}); - vm.file.highlighted_diff_lines = undefined; - vm.file.parallel_diff_lines = []; + vm.file.highlighted_diff_lines = []; + vm.file.parallel_diff_lines = undefined; vm.isCollapsed = true; vm.$nextTick() diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js index 7a083fb6bde..4dcbb3ec332 100644 --- a/spec/frontend/diffs/components/diff_stats_spec.js +++ b/spec/frontend/diffs/components/diff_stats_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import DiffStats from '~/diffs/components/diff_stats.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const TEST_ADDED_LINES = 100; const TEST_REMOVED_LINES = 200; @@ -53,7 +53,7 @@ describe('diff_stats', () => { describe('files changes', () => { const findIcon = name => wrapper - .findAll(Icon) + .findAll(GlIcon) .filter(c => c.attributes('name') === name) .at(0).element.parentNode; diff --git a/spec/frontend/diffs/components/image_diff_overlay_spec.js b/spec/frontend/diffs/components/image_diff_overlay_spec.js index accf0a972d0..5a88a3cabd1 100644 --- a/spec/frontend/diffs/components/image_diff_overlay_spec.js +++ b/spec/frontend/diffs/components/image_diff_overlay_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import ImageDiffOverlay from '~/diffs/components/image_diff_overlay.vue'; import { createStore } from '~/mr_notes/stores'; import { imageDiffDiscussions } from '../mock_data/diff_discussions'; -import Icon from '~/vue_shared/components/icon.vue'; describe('Diffs image diff overlay component', () => { const dimensions = { @@ -64,7 +64,7 @@ describe('Diffs image diff overlay component', () => { it('renders icon when showCommentIcon is true', () => { createComponent({ showCommentIcon: true }); - expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); }); it('sets badge comment positions', () => { diff --git a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js index 90f012fbafe..81e5403d502 100644 --- a/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_expansion_row_spec.js @@ -5,12 +5,13 @@ import InlineDiffExpansionRow from '~/diffs/components/inline_diff_expansion_row import diffFileMockData from '../mock_data/diff_file'; describe('InlineDiffExpansionRow', () => { - const matchLine = diffFileMockData.highlighted_diff_lines[5]; + const mockData = { ...diffFileMockData }; + const matchLine = mockData.highlighted_diff_lines.pop(); const createComponent = (options = {}) => { const cmp = Vue.extend(InlineDiffExpansionRow); const defaults = { - fileHash: diffFileMockData.file_hash, + fileHash: mockData.file_hash, contextLinesPath: 'contextLinesPath', line: matchLine, isTop: false, diff --git a/spec/frontend/diffs/components/inline_diff_table_row_spec.js b/spec/frontend/diffs/components/inline_diff_table_row_spec.js index f929f97b598..951b3f6258b 100644 --- a/spec/frontend/diffs/components/inline_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/inline_diff_table_row_spec.js @@ -1,114 +1,317 @@ import { shallowMount } from '@vue/test-utils'; +import { TEST_HOST } from 'helpers/test_constants'; import { createStore } from '~/mr_notes/stores'; import InlineDiffTableRow from '~/diffs/components/inline_diff_table_row.vue'; +import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import diffFileMockData from '../mock_data/diff_file'; +import discussionsMockData from '../mock_data/diff_discussions'; + +const TEST_USER_ID = 'abc123'; +const TEST_USER = { id: TEST_USER_ID }; describe('InlineDiffTableRow', () => { let wrapper; - let vm; + let store; const thisLine = diffFileMockData.highlighted_diff_lines[0]; - beforeEach(() => { + const createComponent = (props = {}, propsStore = store) => { wrapper = shallowMount(InlineDiffTableRow, { - store: createStore(), + store: propsStore, propsData: { line: thisLine, fileHash: diffFileMockData.file_hash, filePath: diffFileMockData.file_path, contextLinesPath: 'contextLinesPath', isHighlighted: false, + ...props, }, }); - vm = wrapper.vm; + }; + + const setWindowLocation = value => { + Object.defineProperty(window, 'location', { + writable: true, + value, + }); + }; + + beforeEach(() => { + store = createStore(); + store.state.notes.userData = TEST_USER; + }); + + afterEach(() => { + wrapper.destroy(); }); - it('does not add hll class to line content when line does not match highlighted row', done => { - vm.$nextTick() - .then(() => { - expect(wrapper.find('.line_content').classes('hll')).toBe(false); - }) - .then(done) - .catch(done.fail); + it('does not add hll class to line content when line does not match highlighted row', () => { + createComponent(); + expect(wrapper.find('.line_content').classes('hll')).toBe(false); }); - it('adds hll class to lineContent when line is the highlighted row', done => { - vm.$nextTick() - .then(() => { - vm.$store.state.diffs.highlightedRow = thisLine.line_code; - - return vm.$nextTick(); - }) - .then(() => { - expect(wrapper.find('.line_content').classes('hll')).toBe(true); - }) - .then(done) - .catch(done.fail); + it('adds hll class to lineContent when line is the highlighted row', () => { + store.state.diffs.highlightedRow = thisLine.line_code; + createComponent({}, store); + expect(wrapper.find('.line_content').classes('hll')).toBe(true); }); it('adds hll class to lineContent when line is part of a multiline comment', () => { - wrapper.setProps({ isCommented: true }); - return vm.$nextTick().then(() => { - expect(wrapper.find('.line_content').classes('hll')).toBe(true); - }); + createComponent({ isCommented: true }); + expect(wrapper.find('.line_content').classes('hll')).toBe(true); }); describe('sets coverage title and class', () => { - it('for lines with coverage', done => { - vm.$nextTick() - .then(() => { - const name = diffFileMockData.file_path; - const line = thisLine.new_line; - - vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } }; - - return vm.$nextTick(); - }) - .then(() => { - const coverage = wrapper.find('.line-coverage'); - - expect(coverage.attributes('title')).toContain('Test coverage: 5 hits'); - expect(coverage.classes('coverage')).toBe(true); - }) - .then(done) - .catch(done.fail); + it('for lines with coverage', () => { + const name = diffFileMockData.file_path; + const line = thisLine.new_line; + + store.state.diffs.coverageFiles = { files: { [name]: { [line]: 5 } } }; + createComponent({}, store); + const coverage = wrapper.find('.line-coverage'); + + expect(coverage.attributes('title')).toContain('Test coverage: 5 hits'); + expect(coverage.classes('coverage')).toBe(true); + }); + + it('for lines without coverage', () => { + const name = diffFileMockData.file_path; + const line = thisLine.new_line; + + store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } }; + createComponent({}, store); + const coverage = wrapper.find('.line-coverage'); + + expect(coverage.attributes('title')).toContain('No test coverage'); + expect(coverage.classes('no-coverage')).toBe(true); + }); + + it('for unknown lines', () => { + store.state.diffs.coverageFiles = {}; + createComponent({}, store); + + const coverage = wrapper.find('.line-coverage'); + + expect(coverage.attributes('title')).toBeUndefined(); + expect(coverage.classes('coverage')).toBe(false); + expect(coverage.classes('no-coverage')).toBe(false); + }); + }); + + describe('Table Cells', () => { + const findNewTd = () => wrapper.find({ ref: 'newTd' }); + const findOldTd = () => wrapper.find({ ref: 'oldTd' }); + + describe('td', () => { + it('highlights when isHighlighted true', () => { + store.state.diffs.highlightedRow = thisLine.line_code; + createComponent({}, store); + + expect(findNewTd().classes()).toContain('hll'); + expect(findOldTd().classes()).toContain('hll'); + }); + + it('does not highlight when isHighlighted false', () => { + createComponent(); + + expect(findNewTd().classes()).not.toContain('hll'); + expect(findOldTd().classes()).not.toContain('hll'); + }); + }); + + describe('comment button', () => { + const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' }); + + it.each` + userData | query | mergeRefHeadComments | expectation + ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true} + ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true} + ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false} + ${null} | ${''} | ${true} | ${false} + `( + 'exists is $expectation - with userData ($userData) query ($query)', + ({ userData, query, mergeRefHeadComments, expectation }) => { + store.state.notes.userData = userData; + gon.features = { mergeRefHeadComments }; + setWindowLocation({ href: `${TEST_HOST}?${query}` }); + createComponent({}, store); + + expect(findNoteButton().exists()).toBe(expectation); + }, + ); + + it.each` + isHover | line | expectation + ${true} | ${{ ...thisLine, discussions: [] }} | ${true} + ${false} | ${{ ...thisLine, discussions: [] }} | ${false} + ${true} | ${{ ...thisLine, type: 'context', discussions: [] }} | ${false} + ${true} | ${{ ...thisLine, type: 'old-nonewline', discussions: [] }} | ${false} + ${true} | ${{ ...thisLine, discussions: [{}] }} | ${false} + `('visible is $expectation - line ($line)', ({ isHover, line, expectation }) => { + createComponent({ line }); + wrapper.setData({ isHover }); + + return wrapper.vm.$nextTick().then(() => { + expect(findNoteButton().isVisible()).toBe(expectation); + }); + }); + + it.each` + disabled | commentsDisabled + ${'disabled'} | ${true} + ${undefined} | ${false} + `( + 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled', + ({ disabled, commentsDisabled }) => { + createComponent({ + line: { ...thisLine, commentsDisabled }, + }); + + wrapper.setData({ isHover: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(findNoteButton().attributes('disabled')).toBe(disabled); + }); + }, + ); + + const symlinkishFileTooltip = + 'Commenting on symbolic links that replace or are replaced by files is currently not supported.'; + const realishFileTooltip = + 'Commenting on files that replace or are replaced by symbolic links is currently not supported.'; + const otherFileTooltip = 'Add a comment to this line'; + const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' }); + + it.each` + tooltip | commentsDisabled + ${symlinkishFileTooltip} | ${{ wasSymbolic: true }} + ${symlinkishFileTooltip} | ${{ isSymbolic: true }} + ${realishFileTooltip} | ${{ wasReal: true }} + ${realishFileTooltip} | ${{ isReal: true }} + ${otherFileTooltip} | ${false} + `( + 'has the correct tooltip when commentsDisabled=$commentsDisabled', + ({ tooltip, commentsDisabled }) => { + createComponent({ + line: { ...thisLine, commentsDisabled }, + }); + + wrapper.setData({ isHover: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(findTooltip().attributes('title')).toBe(tooltip); + }); + }, + ); }); - it('for lines without coverage', done => { - vm.$nextTick() - .then(() => { - const name = diffFileMockData.file_path; - const line = thisLine.new_line; + describe('line number', () => { + const findLineNumberOld = () => wrapper.find({ ref: 'lineNumberRefOld' }); + const findLineNumberNew = () => wrapper.find({ ref: 'lineNumberRefNew' }); + + it('renders line numbers in correct cells', () => { + createComponent(); + + expect(findLineNumberOld().exists()).toBe(false); + expect(findLineNumberNew().exists()).toBe(true); + }); - vm.$store.state.diffs.coverageFiles = { files: { [name]: { [line]: 0 } } }; + describe('with lineNumber prop', () => { + const TEST_LINE_CODE = 'LC_42'; + const TEST_LINE_NUMBER = 1; - return vm.$nextTick(); - }) - .then(() => { - const coverage = wrapper.find('.line-coverage'); + describe.each` + lineProps | findLineNumber | expectedHref | expectedClickArg + ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} + ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} + ${{ line_code: undefined, left: { line_code: TEST_LINE_CODE }, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${TEST_LINE_CODE} + ${{ line_code: undefined, right: { line_code: TEST_LINE_CODE }, new_line: TEST_LINE_NUMBER }} | ${findLineNumberNew} | ${'#'} | ${TEST_LINE_CODE} + `( + 'with line ($lineProps)', + ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => { + beforeEach(() => { + jest.spyOn(store, 'dispatch').mockImplementation(); + createComponent({ + line: { ...thisLine, ...lineProps }, + }); + }); - expect(coverage.attributes('title')).toContain('No test coverage'); - expect(coverage.classes('no-coverage')).toBe(true); - }) - .then(done) - .catch(done.fail); + it('renders', () => { + expect(findLineNumber().exists()).toBe(true); + expect(findLineNumber().attributes()).toEqual({ + href: expectedHref, + 'data-linenumber': TEST_LINE_NUMBER.toString(), + }); + }); + + it('on click, dispatches setHighlightedRow', () => { + expect(store.dispatch).toHaveBeenCalledTimes(1); + + findLineNumber().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/setHighlightedRow', + expectedClickArg, + ); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }, + ); + }); }); - it('for unknown lines', done => { - vm.$nextTick() - .then(() => { - vm.$store.state.diffs.coverageFiles = {}; - - return vm.$nextTick(); - }) - .then(() => { - const coverage = wrapper.find('.line-coverage'); - - expect(coverage.attributes('title')).toBeUndefined(); - expect(coverage.classes('coverage')).toBe(false); - expect(coverage.classes('no-coverage')).toBe(false); - }) - .then(done) - .catch(done.fail); + describe('diff-gutter-avatars', () => { + const TEST_LINE_CODE = 'LC_42'; + const TEST_FILE_HASH = diffFileMockData.file_hash; + const findAvatars = () => wrapper.find(DiffGutterAvatars); + let line; + + beforeEach(() => { + jest.spyOn(store, 'dispatch').mockImplementation(); + + line = { + line_code: TEST_LINE_CODE, + type: 'new', + old_line: null, + new_line: 1, + discussions: [{ ...discussionsMockData }], + discussionsExpanded: true, + text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + meta_data: null, + }; + }); + + describe('with showCommentButton', () => { + it('renders if line has discussions', () => { + createComponent({ line }); + + expect(findAvatars().props()).toEqual({ + discussions: line.discussions, + discussionsExpanded: line.discussionsExpanded, + }); + }); + + it('does notrender if line has no discussions', () => { + line.discussions = []; + createComponent({ line }); + + expect(findAvatars().exists()).toEqual(false); + }); + + it('toggles line discussion', () => { + createComponent({ line }); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + + findAvatars().vm.$emit('toggleLineDiscussions'); + + expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', { + lineCode: TEST_LINE_CODE, + fileHash: TEST_FILE_HASH, + expanded: !line.discussionsExpanded, + }); + }); + }); }); }); }); diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js index 6c37f86658e..39c581e2796 100644 --- a/spec/frontend/diffs/components/inline_diff_view_spec.js +++ b/spec/frontend/diffs/components/inline_diff_view_spec.js @@ -30,8 +30,8 @@ describe('InlineDiffView', () => { it('should have rendered diff lines', () => { const el = component.$el; - expect(el.querySelectorAll('tr.line_holder').length).toEqual(5); - expect(el.querySelectorAll('tr.line_holder.new').length).toEqual(2); + expect(el.querySelectorAll('tr.line_holder').length).toEqual(8); + expect(el.querySelectorAll('tr.line_holder.new').length).toEqual(4); expect(el.querySelectorAll('tr.line_expansion.match').length).toEqual(1); expect(el.textContent.indexOf('Bad dates')).toBeGreaterThan(-1); }); diff --git a/spec/frontend/diffs/components/merge_conflict_warning_spec.js b/spec/frontend/diffs/components/merge_conflict_warning_spec.js new file mode 100644 index 00000000000..2f303f25f66 --- /dev/null +++ b/spec/frontend/diffs/components/merge_conflict_warning_spec.js @@ -0,0 +1,77 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import MergeConflictWarning from '~/diffs/components/merge_conflict_warning.vue'; +import { CENTERED_LIMITED_CONTAINER_CLASSES } from '~/diffs/constants'; + +const propsData = { + limited: true, + mergeable: true, + resolutionPath: 'a-path', +}; +const limitedClasses = CENTERED_LIMITED_CONTAINER_CLASSES.split(' '); + +function findResolveButton(wrapper) { + return wrapper.find('.gl-alert-actions a.gl-button:first-child'); +} +function findLocalMergeButton(wrapper) { + return wrapper.find('.gl-alert-actions button.gl-button:last-child'); +} + +describe('MergeConflictWarning', () => { + let wrapper; + + const createComponent = (props = {}, { full } = { full: false }) => { + const mounter = full ? mount : shallowMount; + + wrapper = mounter(MergeConflictWarning, { + propsData: { ...propsData, ...props }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it.each` + limited | containerClasses + ${true} | ${limitedClasses} + ${false} | ${[]} + `( + 'has the correct container classes when limited is $limited', + ({ limited, containerClasses }) => { + createComponent({ limited }); + + expect(wrapper.classes()).toEqual(containerClasses); + }, + ); + + it.each` + present | resolutionPath + ${false} | ${''} + ${true} | ${'some-path'} + `( + 'toggles the resolve conflicts button based on the provided resolutionPath "$resolutionPath"', + ({ present, resolutionPath }) => { + createComponent({ resolutionPath }, { full: true }); + const resolveButton = findResolveButton(wrapper); + + expect(resolveButton.exists()).toBe(present); + if (present) { + expect(resolveButton.attributes('href')).toBe(resolutionPath); + } + }, + ); + + it.each` + present | mergeable + ${false} | ${false} + ${true} | ${true} + `( + 'toggles the local merge button based on the provided mergeable property "$mergable"', + ({ present, mergeable }) => { + createComponent({ mergeable }, { full: true }); + const localMerge = findLocalMergeButton(wrapper); + + expect(localMerge.exists()).toBe(present); + }, + ); +}); diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js index 2795c68b4ee..78805a1cddc 100644 --- a/spec/frontend/diffs/components/no_changes_spec.js +++ b/spec/frontend/diffs/components/no_changes_spec.js @@ -36,7 +36,7 @@ describe('Diff no changes empty state', () => { }; }); - expect(vm.contains('script')).toBe(false); + expect(vm.find('script').exists()).toBe(false); }); describe('Renders', () => { diff --git a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js index 339352943a9..13c4ce06f18 100644 --- a/spec/frontend/diffs/components/parallel_diff_table_row_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_table_row_spec.js @@ -1,9 +1,12 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { createStore } from '~/mr_notes/stores'; import ParallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; import diffFileMockData from '../mock_data/diff_file'; +import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; +import discussionsMockData from '../mock_data/diff_discussions'; describe('ParallelDiffTableRow', () => { describe('when one side is empty', () => { @@ -158,4 +161,260 @@ describe('ParallelDiffTableRow', () => { }); }); }); + + describe('Table Cells', () => { + let wrapper; + let store; + let thisLine; + const TEST_USER_ID = 'abc123'; + const TEST_USER = { id: TEST_USER_ID }; + + const createComponent = (props = {}, propsStore = store, data = {}) => { + wrapper = shallowMount(ParallelDiffTableRow, { + store: propsStore, + propsData: { + line: thisLine, + fileHash: diffFileMockData.file_hash, + filePath: diffFileMockData.file_path, + contextLinesPath: 'contextLinesPath', + isHighlighted: false, + ...props, + }, + data() { + return data; + }, + }); + }; + + const setWindowLocation = value => { + Object.defineProperty(window, 'location', { + writable: true, + value, + }); + }; + + beforeEach(() => { + // eslint-disable-next-line prefer-destructuring + thisLine = diffFileMockData.parallel_diff_lines[2]; + store = createStore(); + store.state.notes.userData = TEST_USER; + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const findNewTd = () => wrapper.find({ ref: 'newTd' }); + const findOldTd = () => wrapper.find({ ref: 'oldTd' }); + + describe('td', () => { + it('highlights when isHighlighted true', () => { + store.state.diffs.highlightedRow = thisLine.left.line_code; + createComponent({}, store); + + expect(findNewTd().classes()).toContain('hll'); + expect(findOldTd().classes()).toContain('hll'); + }); + + it('does not highlight when isHighlighted false', () => { + createComponent(); + + expect(findNewTd().classes()).not.toContain('hll'); + expect(findOldTd().classes()).not.toContain('hll'); + }); + }); + + describe('comment button', () => { + const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButtonLeft' }); + + it.each` + hover | line | userData | query | mergeRefHeadComments | expectation + ${true} | ${{}} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${true} + ${true} | ${{ line: { left: null } }} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${false} + ${true} | ${{}} | ${TEST_USER} | ${'diff_head=true'} | ${true} | ${true} + ${true} | ${{}} | ${TEST_USER} | ${'diff_head=true'} | ${false} | ${false} + ${true} | ${{}} | ${null} | ${''} | ${true} | ${false} + ${false} | ${{}} | ${TEST_USER} | ${'diff_head=false'} | ${false} | ${false} + `( + 'exists is $expectation - with userData ($userData) query ($query)', + async ({ hover, line, userData, query, mergeRefHeadComments, expectation }) => { + store.state.notes.userData = userData; + gon.features = { mergeRefHeadComments }; + setWindowLocation({ href: `${TEST_HOST}?${query}` }); + createComponent(line, store); + if (hover) await wrapper.find('.line_holder').trigger('mouseover'); + + expect(findNoteButton().exists()).toBe(expectation); + }, + ); + + it.each` + line | expectation + ${{ ...thisLine, left: { discussions: [] } }} | ${true} + ${{ ...thisLine, left: { type: 'context', discussions: [] } }} | ${false} + ${{ ...thisLine, left: { type: 'old-nonewline', discussions: [] } }} | ${false} + ${{ ...thisLine, left: { discussions: [{}] } }} | ${false} + `('visible is $expectation - line ($line)', async ({ line, expectation }) => { + createComponent({ line }, store, { isLeftHover: true, isCommentButtonRendered: true }); + + expect(findNoteButton().isVisible()).toBe(expectation); + }); + + it.each` + disabled | commentsDisabled + ${'disabled'} | ${true} + ${undefined} | ${false} + `( + 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled', + ({ disabled, commentsDisabled }) => { + thisLine.left.commentsDisabled = commentsDisabled; + createComponent({ line: { ...thisLine } }, store, { + isLeftHover: true, + isCommentButtonRendered: true, + }); + + expect(findNoteButton().attributes('disabled')).toBe(disabled); + }, + ); + + const symlinkishFileTooltip = + 'Commenting on symbolic links that replace or are replaced by files is currently not supported.'; + const realishFileTooltip = + 'Commenting on files that replace or are replaced by symbolic links is currently not supported.'; + const otherFileTooltip = 'Add a comment to this line'; + const findTooltip = () => wrapper.find({ ref: 'addNoteTooltipLeft' }); + + it.each` + tooltip | commentsDisabled + ${symlinkishFileTooltip} | ${{ wasSymbolic: true }} + ${symlinkishFileTooltip} | ${{ isSymbolic: true }} + ${realishFileTooltip} | ${{ wasReal: true }} + ${realishFileTooltip} | ${{ isReal: true }} + ${otherFileTooltip} | ${false} + `( + 'has the correct tooltip when commentsDisabled=$commentsDisabled', + ({ tooltip, commentsDisabled }) => { + thisLine.left.commentsDisabled = commentsDisabled; + createComponent({ line: { ...thisLine } }, store, { + isLeftHover: true, + isCommentButtonRendered: true, + }); + + expect(findTooltip().attributes('title')).toBe(tooltip); + }, + ); + }); + + describe('line number', () => { + const findLineNumberOld = () => wrapper.find({ ref: 'lineNumberRefOld' }); + const findLineNumberNew = () => wrapper.find({ ref: 'lineNumberRefNew' }); + + it('renders line numbers in correct cells', () => { + createComponent(); + + expect(findLineNumberOld().exists()).toBe(true); + expect(findLineNumberNew().exists()).toBe(true); + }); + + describe('with lineNumber prop', () => { + const TEST_LINE_CODE = 'LC_42'; + const TEST_LINE_NUMBER = 1; + + describe.each` + lineProps | findLineNumber | expectedHref | expectedClickArg + ${{ line_code: TEST_LINE_CODE, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${`#${TEST_LINE_CODE}`} | ${TEST_LINE_CODE} + ${{ line_code: undefined, old_line: TEST_LINE_NUMBER }} | ${findLineNumberOld} | ${'#'} | ${undefined} + `( + 'with line ($lineProps)', + ({ lineProps, findLineNumber, expectedHref, expectedClickArg }) => { + beforeEach(() => { + jest.spyOn(store, 'dispatch').mockImplementation(); + Object.assign(thisLine.left, lineProps); + Object.assign(thisLine.right, lineProps); + createComponent({ + line: { ...thisLine }, + }); + }); + + it('renders', () => { + expect(findLineNumber().exists()).toBe(true); + expect(findLineNumber().attributes()).toEqual({ + href: expectedHref, + 'data-linenumber': TEST_LINE_NUMBER.toString(), + }); + }); + + it('on click, dispatches setHighlightedRow', () => { + expect(store.dispatch).toHaveBeenCalledTimes(1); + + findLineNumber().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith( + 'diffs/setHighlightedRow', + expectedClickArg, + ); + expect(store.dispatch).toHaveBeenCalledTimes(2); + }); + }, + ); + }); + }); + + describe('diff-gutter-avatars', () => { + const TEST_LINE_CODE = 'LC_42'; + const TEST_FILE_HASH = diffFileMockData.file_hash; + const findAvatars = () => wrapper.find(DiffGutterAvatars); + let line; + + beforeEach(() => { + jest.spyOn(store, 'dispatch').mockImplementation(); + + line = { + left: { + line_code: TEST_LINE_CODE, + type: 'new', + old_line: null, + new_line: 1, + discussions: [{ ...discussionsMockData }], + discussionsExpanded: true, + text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + meta_data: null, + }, + }; + }); + + describe('with showCommentButton', () => { + it('renders if line has discussions', () => { + createComponent({ line }); + + expect(findAvatars().props()).toEqual({ + discussions: line.left.discussions, + discussionsExpanded: line.left.discussionsExpanded, + }); + }); + + it('does notrender if line has no discussions', () => { + line.left.discussions = []; + createComponent({ line }); + + expect(findAvatars().exists()).toEqual(false); + }); + + it('toggles line discussion', () => { + createComponent({ line }); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + + findAvatars().vm.$emit('toggleLineDiscussions'); + + expect(store.dispatch).toHaveBeenCalledWith('diffs/toggleLineDiscussions', { + lineCode: TEST_LINE_CODE, + fileHash: TEST_FILE_HASH, + expanded: !line.left.discussionsExpanded, + }); + }); + }); + }); + }); }); diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js index cb1a47f60d5..44ed303d0ef 100644 --- a/spec/frontend/diffs/components/parallel_diff_view_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_view_spec.js @@ -1,33 +1,37 @@ -import Vue from 'vue'; -import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import { createStore } from '~/mr_notes/stores'; import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue'; -import * as constants from '~/diffs/constants'; +import parallelDiffTableRow from '~/diffs/components/parallel_diff_table_row.vue'; import diffFileMockData from '../mock_data/diff_file'; -describe('ParallelDiffView', () => { - let component; - const getDiffFileMock = () => ({ ...diffFileMockData }); +let wrapper; +const localVue = createLocalVue(); + +localVue.use(Vuex); - beforeEach(() => { - const diffFile = getDiffFileMock(); +function factory() { + const diffFile = { ...diffFileMockData }; + const store = createStore(); - component = createComponentWithStore(Vue.extend(ParallelDiffView), createStore(), { + wrapper = shallowMount(ParallelDiffView, { + localVue, + store, + propsData: { diffFile, diffLines: diffFile.parallel_diff_lines, - }).$mount(); + }, }); +} +describe('ParallelDiffView', () => { afterEach(() => { - component.$destroy(); + wrapper.destroy(); }); - describe('assigned', () => { - describe('diffLines', () => { - it('should normalize lines for empty cells', () => { - expect(component.diffLines[0].left.type).toEqual(constants.EMPTY_CELL_TYPE); - expect(component.diffLines[1].left.type).toEqual(constants.EMPTY_CELL_TYPE); - }); - }); + it('renders diff lines', () => { + factory(); + + expect(wrapper.findAll(parallelDiffTableRow).length).toBe(8); }); }); diff --git a/spec/frontend/diffs/components/settings_dropdown_spec.js b/spec/frontend/diffs/components/settings_dropdown_spec.js index 2e95d79ea49..72330d8efba 100644 --- a/spec/frontend/diffs/components/settings_dropdown_spec.js +++ b/spec/frontend/diffs/components/settings_dropdown_spec.js @@ -7,7 +7,7 @@ import { PARALLEL_DIFF_VIEW_TYPE, INLINE_DIFF_VIEW_TYPE } from '~/diffs/constant const localVue = createLocalVue(); localVue.use(Vuex); -describe('Diff settiings dropdown component', () => { +describe('Diff settings dropdown component', () => { let vm; let actions; @@ -50,7 +50,7 @@ describe('Diff settiings dropdown component', () => { vm.find('.js-list-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false, undefined); + expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), false); }); it('tree view button dispatches setRenderTreeList with true', () => { @@ -58,53 +58,53 @@ describe('Diff settiings dropdown component', () => { vm.find('.js-tree-view').trigger('click'); - expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true, undefined); + expect(actions.setRenderTreeList).toHaveBeenCalledWith(expect.anything(), true); }); - it('sets list button as active when renderTreeList is false', () => { + it('sets list button as selected when renderTreeList is false', () => { createComponent(store => { Object.assign(store.state.diffs, { renderTreeList: false, }); }); - expect(vm.find('.js-list-view').classes('active')).toBe(true); - expect(vm.find('.js-tree-view').classes('active')).toBe(false); + expect(vm.find('.js-list-view').classes('selected')).toBe(true); + expect(vm.find('.js-tree-view').classes('selected')).toBe(false); }); - it('sets tree button as active when renderTreeList is true', () => { + it('sets tree button as selected when renderTreeList is true', () => { createComponent(store => { Object.assign(store.state.diffs, { renderTreeList: true, }); }); - expect(vm.find('.js-list-view').classes('active')).toBe(false); - expect(vm.find('.js-tree-view').classes('active')).toBe(true); + expect(vm.find('.js-list-view').classes('selected')).toBe(false); + expect(vm.find('.js-tree-view').classes('selected')).toBe(true); }); }); describe('compare changes', () => { - it('sets inline button as active', () => { + it('sets inline button as selected', () => { createComponent(store => { Object.assign(store.state.diffs, { diffViewType: INLINE_DIFF_VIEW_TYPE, }); }); - expect(vm.find('.js-inline-diff-button').classes('active')).toBe(true); - expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(false); + expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(true); + expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(false); }); - it('sets parallel button as active', () => { + it('sets parallel button as selected', () => { createComponent(store => { Object.assign(store.state.diffs, { diffViewType: PARALLEL_DIFF_VIEW_TYPE, }); }); - expect(vm.find('.js-inline-diff-button').classes('active')).toBe(false); - expect(vm.find('.js-parallel-diff-button').classes('active')).toBe(true); + expect(vm.find('.js-inline-diff-button').classes('selected')).toBe(false); + expect(vm.find('.js-parallel-diff-button').classes('selected')).toBe(true); }); it('calls setInlineDiffViewType when clicking inline button', () => { @@ -153,14 +153,10 @@ describe('Diff settiings dropdown component', () => { checkbox.element.checked = true; checkbox.trigger('change'); - expect(actions.setShowWhitespace).toHaveBeenCalledWith( - expect.anything(), - { - showWhitespace: true, - pushState: true, - }, - undefined, - ); + expect(actions.setShowWhitespace).toHaveBeenCalledWith(expect.anything(), { + showWhitespace: true, + pushState: true, + }); }); }); }); diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index 14cb2a17aec..cc177a81d88 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -1,16 +1,26 @@ import Vuex from 'vuex'; -import { mount, createLocalVue } from '@vue/test-utils'; +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; import TreeList from '~/diffs/components/tree_list.vue'; import createStore from '~/diffs/store/modules'; +import FileTree from '~/vue_shared/components/file_tree.vue'; describe('Diffs tree list component', () => { let wrapper; + let store; const getFileRows = () => wrapper.findAll('.file-row'); const localVue = createLocalVue(); localVue.use(Vuex); - const createComponent = state => { - const store = new Vuex.Store({ + const createComponent = (mountFn = mount) => { + wrapper = mountFn(TreeList, { + store, + localVue, + propsData: { hideFileStats: false }, + }); + }; + + beforeEach(() => { + store = new Vuex.Store({ modules: { diffs: createStore(), }, @@ -23,61 +33,57 @@ describe('Diffs tree list component', () => { addedLines: 10, removedLines: 20, ...store.state.diffs, - ...state, }; + }); - wrapper = mount(TreeList, { - store, - localVue, - propsData: { hideFileStats: false }, + const setupFilesInState = () => { + const treeEntries = { + 'index.js': { + addedLines: 0, + changed: true, + deleted: false, + fileHash: 'test', + key: 'index.js', + name: 'index.js', + path: 'app/index.js', + removedLines: 0, + tempFile: true, + type: 'blob', + parentPath: 'app', + }, + app: { + key: 'app', + path: 'app', + name: 'app', + type: 'tree', + tree: [], + }, + }; + + Object.assign(store.state.diffs, { + treeEntries, + tree: [treeEntries['index.js'], treeEntries.app], }); }; - beforeEach(() => { - localStorage.removeItem('mr_diff_tree_list'); - - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); - it('renders empty text', () => { - expect(wrapper.text()).toContain('No files found'); + describe('default', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders empty text', () => { + expect(wrapper.text()).toContain('No files found'); + }); }); describe('with files', () => { beforeEach(() => { - const treeEntries = { - 'index.js': { - addedLines: 0, - changed: true, - deleted: false, - fileHash: 'test', - key: 'index.js', - name: 'index.js', - path: 'app/index.js', - removedLines: 0, - tempFile: true, - type: 'blob', - parentPath: 'app', - }, - app: { - key: 'app', - path: 'app', - name: 'app', - type: 'tree', - tree: [], - }, - }; - - createComponent({ - treeEntries, - tree: [treeEntries['index.js'], treeEntries.app], - }); - - return wrapper.vm.$nextTick(); + setupFilesInState(); + createComponent(); }); it('renders tree', () => { @@ -136,4 +142,23 @@ describe('Diffs tree list component', () => { }); }); }); + + describe('with viewedDiffFileIds', () => { + const viewedDiffFileIds = { fileId: '#12345' }; + + beforeEach(() => { + setupFilesInState(); + store.state.diffs.viewedDiffFileIds = viewedDiffFileIds; + }); + + it('passes the viewedDiffFileIds to the FileTree', () => { + createComponent(shallowMount); + + return wrapper.vm.$nextTick().then(() => { + // Have to use $attrs['viewed-files'] because we are passing down an object + // and attributes('') stringifies values (e.g. [object])... + expect(wrapper.find(FileTree).vm.$attrs['viewed-files']).toBe(viewedDiffFileIds); + }); + }); + }); }); diff --git a/spec/frontend/diffs/mock_data/diff_file.js b/spec/frontend/diffs/mock_data/diff_file.js index e4b2fdf6ede..c2a4424ee95 100644 --- a/spec/frontend/diffs/mock_data/diff_file.js +++ b/spec/frontend/diffs/mock_data/diff_file.js @@ -56,8 +56,8 @@ export default { old_line: null, new_line: 1, discussions: [], - text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', - rich_text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', meta_data: null, }, { @@ -66,8 +66,8 @@ export default { old_line: null, new_line: 2, discussions: [], - text: '+<span id="LC2" class="line" lang="plaintext"></span>\n', - rich_text: '+<span id="LC2" class="line" lang="plaintext"></span>\n', + text: '<span id="LC2" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n', meta_data: null, }, { @@ -76,8 +76,8 @@ export default { old_line: 1, new_line: 3, discussions: [], - text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', - rich_text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', meta_data: null, }, { @@ -86,8 +86,8 @@ export default { old_line: 2, new_line: 4, discussions: [], - text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', - rich_text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + text: '<span id="LC4" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', meta_data: null, }, { @@ -96,8 +96,38 @@ export default { old_line: 3, new_line: 5, discussions: [], - text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', - rich_text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_6', + type: 'old', + old_line: 4, + new_line: null, + discussions: [], + text: '<span id="LC6" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC6" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_7', + type: 'new', + old_line: null, + new_line: 5, + discussions: [], + text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_9', + type: 'new', + old_line: null, + new_line: 6, + discussions: [], + text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n', meta_data: null, }, { @@ -116,43 +146,39 @@ export default { ], parallel_diff_lines: [ { - left: { - type: 'empty-cell', - }, + left: null, right: { line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_1', type: 'new', old_line: null, new_line: 1, discussions: [], - text: '+<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', + text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', rich_text: '<span id="LC1" class="line" lang="plaintext"> - Bad dates</span>\n', meta_data: null, }, }, { - left: { - type: 'empty-cell', - }, + left: null, right: { line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_2', type: 'new', old_line: null, new_line: 2, discussions: [], - text: '+<span id="LC2" class="line" lang="plaintext"></span>\n', + text: '<span id="LC2" class="line" lang="plaintext"></span>\n', rich_text: '<span id="LC2" class="line" lang="plaintext"></span>\n', meta_data: null, }, }, { left: { - line_Code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_3', type: null, old_line: 1, new_line: 3, discussions: [], - text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', meta_data: null, }, @@ -162,7 +188,7 @@ export default { old_line: 1, new_line: 3, discussions: [], - text: ' <span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', + text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', rich_text: '<span id="LC3" class="line" lang="plaintext">v6.8.0</span>\n', meta_data: null, }, @@ -174,7 +200,7 @@ export default { old_line: 2, new_line: 4, discussions: [], - text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + text: '<span id="LC4" class="line" lang="plaintext"></span>\n', rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', meta_data: null, }, @@ -184,7 +210,7 @@ export default { old_line: 2, new_line: 4, discussions: [], - text: ' <span id="LC4" class="line" lang="plaintext"></span>\n', + text: '<span id="LC4" class="line" lang="plaintext"></span>\n', rich_text: '<span id="LC4" class="line" lang="plaintext"></span>\n', meta_data: null, }, @@ -196,7 +222,7 @@ export default { old_line: 3, new_line: 5, discussions: [], - text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', meta_data: null, }, @@ -206,13 +232,48 @@ export default { old_line: 3, new_line: 5, discussions: [], - text: ' <span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', + text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', rich_text: '<span id="LC5" class="line" lang="plaintext">v6.7.0</span>\n', meta_data: null, }, }, { left: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_6', + type: 'old', + old_line: 4, + new_line: null, + discussions: [], + text: '<span id="LC6" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC6" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_7', + type: 'new', + old_line: null, + new_line: 5, + discussions: [], + text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + }, + { + left: null, + right: { + line_code: '1c497fbb3a46b78edf04cc2a2fa33f67e3ffbe2a_1_9', + type: 'new', + old_line: null, + new_line: 6, + discussions: [], + text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + rich_text: '<span id="LC7" class="line" lang="plaintext"></span>\n', + meta_data: null, + }, + }, + { + left: { line_code: null, type: 'match', old_line: null, diff --git a/spec/frontend/diffs/mock_data/diff_metadata.js b/spec/frontend/diffs/mock_data/diff_metadata.js index b73b29e4bc8..cfa0038c06f 100644 --- a/spec/frontend/diffs/mock_data/diff_metadata.js +++ b/spec/frontend/diffs/mock_data/diff_metadata.js @@ -1,6 +1,3 @@ -/* eslint-disable import/prefer-default-export */ -/* https://gitlab.com/gitlab-org/frontend/rfcs/-/issues/20 */ - export const diffMetadata = { real_size: '1', size: 1, diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 5fef35d6c5b..4f647b0cd41 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -13,7 +13,6 @@ import { } from '~/diffs/constants'; import { setBaseConfig, - fetchDiffFiles, fetchDiffFilesBatch, fetchDiffFilesMeta, fetchCoverageFiles, @@ -101,7 +100,6 @@ describe('DiffsStoreActions', () => { const projectPath = '/root/project'; const dismissEndpoint = '/-/user_callouts'; const showSuggestPopover = false; - const useSingleDiffStyle = false; testAction( setBaseConfig, @@ -113,7 +111,6 @@ describe('DiffsStoreActions', () => { projectPath, dismissEndpoint, showSuggestPopover, - useSingleDiffStyle, }, { endpoint: '', @@ -123,7 +120,6 @@ describe('DiffsStoreActions', () => { projectPath: '', dismissEndpoint: '', showSuggestPopover: true, - useSingleDiffStyle: true, }, [ { @@ -136,7 +132,6 @@ describe('DiffsStoreActions', () => { projectPath, dismissEndpoint, showSuggestPopover, - useSingleDiffStyle, }, }, ], @@ -146,39 +141,6 @@ describe('DiffsStoreActions', () => { }); }); - describe('fetchDiffFiles', () => { - it('should fetch diff files', done => { - const endpoint = '/fetch/diff/files?view=inline&w=1'; - const mock = new MockAdapter(axios); - const res = { diff_files: 1, merge_request_diffs: [] }; - mock.onGet(endpoint).reply(200, res); - - testAction( - fetchDiffFiles, - {}, - { endpoint, diffFiles: [], showWhitespace: false, diffViewType: 'inline' }, - [ - { type: types.SET_LOADING, payload: true }, - { type: types.SET_LOADING, payload: false }, - { type: types.SET_MERGE_REQUEST_DIFFS, payload: res.merge_request_diffs }, - { type: types.SET_DIFF_DATA, payload: res }, - ], - [], - () => { - mock.restore(); - done(); - }, - ); - - fetchDiffFiles({ state: { endpoint }, commit: () => null }) - .then(data => { - expect(data).toEqual(res); - done(); - }) - .catch(done.fail); - }); - }); - describe('fetchDiffFilesBatch', () => { let mock; @@ -223,16 +185,16 @@ describe('DiffsStoreActions', () => { testAction( fetchDiffFilesBatch, {}, - { endpointBatch, useSingleDiffStyle: true, diffViewType: 'inline' }, + { endpointBatch, diffViewType: 'inline' }, [ { type: types.SET_BATCH_LOADING, payload: true }, { type: types.SET_RETRIEVING_BATCHES, payload: true }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, - { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test' }, + { type: types.VIEW_DIFF_FILE, payload: 'test' }, { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, { type: types.SET_BATCH_LOADING, payload: false }, - { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test2' }, + { type: types.VIEW_DIFF_FILE, payload: 'test2' }, { type: types.SET_RETRIEVING_BATCHES, payload: false }, ], [], @@ -253,7 +215,6 @@ describe('DiffsStoreActions', () => { commit: () => {}, state: { endpointBatch: `${endpointBatch}?view=${otherView}`, - useSingleDiffStyle: true, diffViewType: viewStyle, }, }) @@ -283,7 +244,7 @@ describe('DiffsStoreActions', () => { testAction( fetchDiffFilesMeta, {}, - { endpointMetadata }, + { endpointMetadata, diffViewType: 'inline' }, [ { type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }, @@ -299,146 +260,6 @@ describe('DiffsStoreActions', () => { }); }); - describe('when the single diff view feature flag is off', () => { - describe('fetchDiffFiles', () => { - it('should fetch diff files', done => { - const endpoint = '/fetch/diff/files?w=1'; - const mock = new MockAdapter(axios); - const res = { diff_files: 1, merge_request_diffs: [] }; - mock.onGet(endpoint).reply(200, res); - - testAction( - fetchDiffFiles, - {}, - { - endpoint, - diffFiles: [], - showWhitespace: false, - diffViewType: 'inline', - useSingleDiffStyle: false, - currentDiffFileId: null, - }, - [ - { type: types.SET_LOADING, payload: true }, - { type: types.SET_LOADING, payload: false }, - { type: types.SET_MERGE_REQUEST_DIFFS, payload: res.merge_request_diffs }, - { type: types.SET_DIFF_DATA, payload: res }, - ], - [], - () => { - mock.restore(); - done(); - }, - ); - - fetchDiffFiles({ state: { endpoint }, commit: () => null }) - .then(data => { - expect(data).toEqual(res); - done(); - }) - .catch(done.fail); - }); - }); - - describe('fetchDiffFilesBatch', () => { - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - }); - - afterEach(() => { - mock.restore(); - }); - - it('should fetch batch diff files', done => { - const endpointBatch = '/fetch/diffs_batch'; - const res1 = { diff_files: [{ file_hash: 'test' }], pagination: { next_page: 2 } }; - const res2 = { diff_files: [{ file_hash: 'test2' }], pagination: {} }; - mock - .onGet(mergeUrlParams({ per_page: DIFFS_PER_PAGE, w: '1', page: 1 }, endpointBatch)) - .reply(200, res1) - .onGet(mergeUrlParams({ per_page: DIFFS_PER_PAGE, w: '1', page: 2 }, endpointBatch)) - .reply(200, res2); - - testAction( - fetchDiffFilesBatch, - {}, - { endpointBatch, useSingleDiffStyle: false, currentDiffFileId: null }, - [ - { type: types.SET_BATCH_LOADING, payload: true }, - { type: types.SET_RETRIEVING_BATCHES, payload: true }, - { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res1.diff_files } }, - { type: types.SET_BATCH_LOADING, payload: false }, - { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test' }, - { type: types.SET_DIFF_DATA_BATCH, payload: { diff_files: res2.diff_files } }, - { type: types.SET_BATCH_LOADING, payload: false }, - { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'test2' }, - { type: types.SET_RETRIEVING_BATCHES, payload: false }, - ], - [], - done, - ); - }); - - it.each` - querystrings | requestUrl - ${'?view=parallel'} | ${'/fetch/diffs_batch?view=parallel'} - ${'?view=inline'} | ${'/fetch/diffs_batch?view=inline'} - ${''} | ${'/fetch/diffs_batch'} - `( - 'should use the endpoint $requestUrl if the endpointBatch in state includes `$querystrings` as a querystring', - ({ querystrings, requestUrl }) => { - const endpointBatch = '/fetch/diffs_batch'; - - fetchDiffFilesBatch({ - commit: () => {}, - state: { - endpointBatch: `${endpointBatch}${querystrings}`, - diffViewType: 'inline', - }, - }) - .then(() => { - expect(mock.history.get[0].url).toEqual(requestUrl); - }) - .catch(() => {}); - }, - ); - }); - - describe('fetchDiffFilesMeta', () => { - const endpointMetadata = '/fetch/diffs_metadata.json'; - const noFilesData = { ...diffMetadata }; - let mock; - - beforeEach(() => { - mock = new MockAdapter(axios); - - delete noFilesData.diff_files; - - mock.onGet(endpointMetadata).reply(200, diffMetadata); - }); - it('should fetch diff meta information', done => { - testAction( - fetchDiffFilesMeta, - {}, - { endpointMetadata, useSingleDiffStyle: false }, - [ - { type: types.SET_LOADING, payload: true }, - { type: types.SET_LOADING, payload: false }, - { type: types.SET_MERGE_REQUEST_DIFFS, payload: diffMetadata.merge_request_diffs }, - { type: types.SET_DIFF_DATA, payload: noFilesData }, - ], - [], - () => { - mock.restore(); - done(); - }, - ); - }); - }); - }); - describe('fetchCoverageFiles', () => { let mock; const endpointCoverage = '/fetch'; @@ -479,7 +300,7 @@ describe('DiffsStoreActions', () => { it('should mark currently selected diff and set lineHash and fileHash of highlightedRow', () => { testAction(setHighlightedRow, 'ABC_123', {}, [ { type: types.SET_HIGHLIGHTED_ROW, payload: 'ABC_123' }, - { type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: 'ABC' }, + { type: types.VIEW_DIFF_FILE, payload: 'ABC' }, ]); }); }); @@ -589,7 +410,7 @@ describe('DiffsStoreActions', () => { testAction( assignDiscussionsToDiff, [], - { diffFiles: [], useSingleDiffStyle: true }, + { diffFiles: [] }, [], [{ type: 'setCurrentDiffFileIdFromNote', payload: '123' }], done, @@ -1083,7 +904,7 @@ describe('DiffsStoreActions', () => { expect(document.location.hash).toBe('#test'); }); - it('commits UPDATE_CURRENT_DIFF_FILE_ID', () => { + it('commits VIEW_DIFF_FILE', () => { const state = { treeEntries: { path: { @@ -1094,7 +915,7 @@ describe('DiffsStoreActions', () => { scrollToFile({ state, commit }, 'path'); - expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, 'test'); + expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, 'test'); }); }); @@ -1592,7 +1413,7 @@ describe('DiffsStoreActions', () => { }); describe('setCurrentDiffFileIdFromNote', () => { - it('commits UPDATE_CURRENT_DIFF_FILE_ID', () => { + it('commits VIEW_DIFF_FILE', () => { const commit = jest.fn(); const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { @@ -1602,10 +1423,10 @@ describe('DiffsStoreActions', () => { setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); - expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, '123'); + expect(commit).toHaveBeenCalledWith(types.VIEW_DIFF_FILE, '123'); }); - it('does not commit UPDATE_CURRENT_DIFF_FILE_ID when discussion has no diff_file', () => { + it('does not commit VIEW_DIFF_FILE when discussion has no diff_file', () => { const commit = jest.fn(); const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { @@ -1618,7 +1439,7 @@ describe('DiffsStoreActions', () => { expect(commit).not.toHaveBeenCalled(); }); - it('does not commit UPDATE_CURRENT_DIFF_FILE_ID when diff file does not exist', () => { + it('does not commit VIEW_DIFF_FILE when diff file does not exist', () => { const commit = jest.fn(); const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { @@ -1633,12 +1454,12 @@ describe('DiffsStoreActions', () => { }); describe('navigateToDiffFileIndex', () => { - it('commits UPDATE_CURRENT_DIFF_FILE_ID', done => { + it('commits VIEW_DIFF_FILE', done => { testAction( navigateToDiffFileIndex, 0, { diffFiles: [{ file_hash: '123' }] }, - [{ type: types.UPDATE_CURRENT_DIFF_FILE_ID, payload: '123' }], + [{ type: types.VIEW_DIFF_FILE, payload: '123' }], [], done, ); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index 70047899612..e1d855ae0cf 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -11,13 +11,11 @@ describe('DiffsStoreMutations', () => { const state = {}; const endpoint = '/diffs/endpoint'; const projectPath = '/root/project'; - const useSingleDiffStyle = false; - mutations[types.SET_BASE_CONFIG](state, { endpoint, projectPath, useSingleDiffStyle }); + mutations[types.SET_BASE_CONFIG](state, { endpoint, projectPath }); expect(state.endpoint).toEqual(endpoint); expect(state.projectPath).toEqual(projectPath); - expect(state.useSingleDiffStyle).toEqual(useSingleDiffStyle); }); }); @@ -70,12 +68,13 @@ describe('DiffsStoreMutations', () => { }); describe('SET_DIFF_DATA', () => { - it('should set diff data type properly', () => { + it('should not modify the existing state', () => { const state = { diffFiles: [ { - ...diffFileMockData, - parallel_diff_lines: [], + content_sha: diffFileMockData.content_sha, + file_hash: diffFileMockData.file_hash, + highlighted_diff_lines: [], }, ], }; @@ -85,43 +84,7 @@ describe('DiffsStoreMutations', () => { mutations[types.SET_DIFF_DATA](state, diffMock); - const firstLine = state.diffFiles[0].parallel_diff_lines[0]; - - expect(firstLine.right.text).toBeUndefined(); - expect(state.diffFiles.length).toEqual(1); - expect(state.diffFiles[0].renderIt).toEqual(true); - expect(state.diffFiles[0].collapsed).toEqual(false); - }); - - describe('given diffsBatchLoad feature flag is enabled', () => { - beforeEach(() => { - gon.features = { diffsBatchLoad: true }; - }); - - afterEach(() => { - delete gon.features; - }); - - it('should not modify the existing state', () => { - const state = { - diffFiles: [ - { - content_sha: diffFileMockData.content_sha, - file_hash: diffFileMockData.file_hash, - highlighted_diff_lines: [], - }, - ], - }; - const diffMock = { - diff_files: [diffFileMockData], - }; - - mutations[types.SET_DIFF_DATA](state, diffMock); - - // If the batch load is enabled, there shouldn't be any processing - // done on the existing state object, so we shouldn't have this. - expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined(); - }); + expect(state.diffFiles[0].parallel_diff_lines).toBeUndefined(); }); }); @@ -682,6 +645,36 @@ describe('DiffsStoreMutations', () => { expect(state.diffFiles[0].highlighted_diff_lines[0].discussions).toHaveLength(1); expect(state.diffFiles[0].highlighted_diff_lines[0].discussions[0].id).toBe(1); }); + + it('should add discussion to file', () => { + const state = { + latestDiff: true, + diffFiles: [ + { + file_hash: 'ABC', + discussions: [], + parallel_diff_lines: [], + highlighted_diff_lines: [], + }, + ], + }; + const discussion = { + id: 1, + line_code: 'ABC_1', + diff_discussion: true, + resolvable: true, + diff_file: { + file_hash: state.diffFiles[0].file_hash, + }, + }; + + mutations[types.SET_LINE_DISCUSSIONS_FOR_FILE](state, { + discussion, + diffPositionByLineCode: null, + }); + + expect(state.diffFiles[0].discussions.length).toEqual(1); + }); }); describe('REMOVE_LINE_DISCUSSIONS', () => { @@ -774,11 +767,11 @@ describe('DiffsStoreMutations', () => { }); }); - describe('UPDATE_CURRENT_DIFF_FILE_ID', () => { + describe('VIEW_DIFF_FILE', () => { it('updates currentDiffFileId', () => { const state = createState(); - mutations[types.UPDATE_CURRENT_DIFF_FILE_ID](state, 'somefileid'); + mutations[types.VIEW_DIFF_FILE](state, 'somefileid'); expect(state.currentDiffFileId).toBe('somefileid'); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index 62c82468ea0..39a482c85ae 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -1167,4 +1167,59 @@ describe('DiffsStoreUtils', () => { expect(utils.getDefaultWhitespace(undefined, '0')).toBe(true); }); }); + + describe('isAdded', () => { + it.each` + type | expected + ${'new'} | ${true} + ${'new-nonewline'} | ${true} + ${'old'} | ${false} + `('returns $expected when type is $type', ({ type, expected }) => { + expect(utils.isAdded({ type })).toBe(expected); + }); + }); + + describe('isRemoved', () => { + it.each` + type | expected + ${'old'} | ${true} + ${'old-nonewline'} | ${true} + ${'new'} | ${false} + `('returns $expected when type is $type', ({ type, expected }) => { + expect(utils.isRemoved({ type })).toBe(expected); + }); + }); + + describe('isUnchanged', () => { + it.each` + type | expected + ${null} | ${true} + ${'new'} | ${false} + ${'old'} | ${false} + `('returns $expected when type is $type', ({ type, expected }) => { + expect(utils.isUnchanged({ type })).toBe(expected); + }); + }); + + describe('isMeta', () => { + it.each` + type | expected + ${'match'} | ${true} + ${'new-nonewline'} | ${true} + ${'old-nonewline'} | ${true} + ${'new'} | ${false} + `('returns $expected when type is $type', ({ type, expected }) => { + expect(utils.isMeta({ type })).toBe(expected); + }); + }); + + describe('parallelizeDiffLines', () => { + it('converts inline diff lines to parallel diff lines', () => { + const file = getDiffFileMock(); + + expect(utils.parallelizeDiffLines(file.highlighted_diff_lines)).toEqual( + file.parallel_diff_lines, + ); + }); + }); }); diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js index e4edeab172b..e566d3a4b38 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/editor_lite_spec.js @@ -1,8 +1,7 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monaco-editor'; import Editor from '~/editor/editor_lite'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; - -const URI_PREFIX = 'gitlab'; +import { EDITOR_LITE_INSTANCE_ERROR_NO_EL, URI_PREFIX } from '~/editor/constants'; describe('Base editor', () => { let editorEl; @@ -27,9 +26,7 @@ describe('Base editor', () => { it('initializes Editor with basic properties', () => { expect(editor).toBeDefined(); - expect(editor.editorEl).toBe(null); - expect(editor.blobContent).toEqual(''); - expect(editor.blobPath).toEqual(''); + expect(editor.instances).toEqual([]); }); it('removes `editor-loading` data attribute from the target DOM element', () => { @@ -51,15 +48,14 @@ describe('Base editor', () => { instanceSpy = jest.spyOn(monacoEditor, 'create').mockImplementation(() => ({ setModel, dispose, + onDidDispose: jest.fn(), })); }); - it('does nothing if no dom element is supplied', () => { - editor.createInstance(); - - expect(editor.editorEl).toBe(null); - expect(editor.blobContent).toEqual(''); - expect(editor.blobPath).toEqual(''); + it('throws an error if no dom element is supplied', () => { + expect(() => { + editor.createInstance(); + }).toThrow(EDITOR_LITE_INSTANCE_ERROR_NO_EL); expect(modelSpy).not.toHaveBeenCalled(); expect(instanceSpy).not.toHaveBeenCalled(); @@ -89,15 +85,133 @@ describe('Base editor', () => { createUri(blobGlobalId, blobPath), ); }); + + it('initializes instance with passed properties', () => { + const instanceOptions = { + foo: 'bar', + }; + editor.createInstance({ + el: editorEl, + ...instanceOptions, + }); + expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.objectContaining(instanceOptions)); + }); + + it('disposes instance when the editor is disposed', () => { + editor.createInstance({ el: editorEl, blobPath, blobContent, blobGlobalId }); + + expect(dispose).not.toHaveBeenCalled(); + + editor.dispose(); + + expect(dispose).toHaveBeenCalled(); + }); + }); + + describe('multiple instances', () => { + let instanceSpy; + let inst1Args; + let inst2Args; + let editorEl1; + let editorEl2; + let inst1; + let inst2; + const readOnlyIndex = '68'; // readOnly option has the internal index of 68 in the editor's options + + beforeEach(() => { + setFixtures('<div id="editor1"></div><div id="editor2"></div>'); + editorEl1 = document.getElementById('editor1'); + editorEl2 = document.getElementById('editor2'); + inst1Args = { + el: editorEl1, + blobGlobalId, + }; + inst2Args = { + el: editorEl2, + blobContent, + blobPath, + blobGlobalId, + }; + + editor = new Editor(); + instanceSpy = jest.spyOn(monacoEditor, 'create'); + }); + + afterEach(() => { + editor.dispose(); + }); + + it('can initialize several instances of the same editor', () => { + editor.createInstance(inst1Args); + expect(editor.instances).toHaveLength(1); + + editor.createInstance(inst2Args); + + expect(instanceSpy).toHaveBeenCalledTimes(2); + expect(editor.instances).toHaveLength(2); + }); + + it('sets independent models on independent instances', () => { + inst1 = editor.createInstance(inst1Args); + inst2 = editor.createInstance(inst2Args); + + const model1 = inst1.getModel(); + const model2 = inst2.getModel(); + + expect(model1).toBeDefined(); + expect(model2).toBeDefined(); + expect(model1).not.toEqual(model2); + }); + + it('shares global editor options among all instances', () => { + editor = new Editor({ + readOnly: true, + }); + + inst1 = editor.createInstance(inst1Args); + expect(inst1.getOption(readOnlyIndex)).toBe(true); + + inst2 = editor.createInstance(inst2Args); + expect(inst2.getOption(readOnlyIndex)).toBe(true); + }); + + it('allows overriding editor options on the instance level', () => { + editor = new Editor({ + readOnly: true, + }); + inst1 = editor.createInstance({ + ...inst1Args, + readOnly: false, + }); + + expect(inst1.getOption(readOnlyIndex)).toBe(false); + }); + + it('disposes instances and relevant models independently from each other', () => { + inst1 = editor.createInstance(inst1Args); + inst2 = editor.createInstance(inst2Args); + + expect(inst1.getModel()).not.toBe(null); + expect(inst2.getModel()).not.toBe(null); + expect(editor.instances).toHaveLength(2); + expect(monacoEditor.getModels()).toHaveLength(2); + + inst1.dispose(); + expect(inst1.getModel()).toBe(null); + expect(inst2.getModel()).not.toBe(null); + expect(editor.instances).toHaveLength(1); + expect(monacoEditor.getModels()).toHaveLength(1); + }); }); describe('implementation', () => { + let instance; beforeEach(() => { - editor.createInstance({ el: editorEl, blobPath, blobContent }); + instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); }); it('correctly proxies value from the model', () => { - expect(editor.getValue()).toEqual(blobContent); + expect(instance.getValue()).toBe(blobContent); }); it('is capable of changing the language of the model', () => { @@ -108,24 +222,25 @@ describe('Base editor', () => { const blobRenamedPath = 'test.js'; - expect(editor.model.getLanguageIdentifier().language).toEqual('markdown'); - editor.updateModelLanguage(blobRenamedPath); + expect(instance.getModel().getLanguageIdentifier().language).toBe('markdown'); + instance.updateModelLanguage(blobRenamedPath); - expect(editor.model.getLanguageIdentifier().language).toEqual('javascript'); + expect(instance.getModel().getLanguageIdentifier().language).toBe('javascript'); }); it('falls back to plaintext if there is no language associated with an extension', () => { const blobRenamedPath = 'test.myext'; const spy = jest.spyOn(console, 'error').mockImplementation(() => {}); - editor.updateModelLanguage(blobRenamedPath); + instance.updateModelLanguage(blobRenamedPath); expect(spy).not.toHaveBeenCalled(); - expect(editor.model.getLanguageIdentifier().language).toEqual('plaintext'); + expect(instance.getModel().getLanguageIdentifier().language).toBe('plaintext'); }); }); describe('extensions', () => { + let instance; const foo1 = jest.fn(); const foo2 = jest.fn(); const bar = jest.fn(); @@ -139,14 +254,14 @@ describe('Base editor', () => { foo: foo2, }; beforeEach(() => { - editor.createInstance({ el: editorEl, blobPath, blobContent }); + instance = editor.createInstance({ el: editorEl, blobPath, blobContent }); }); it('is extensible with the extensions', () => { - expect(editor.foo).toBeUndefined(); + expect(instance.foo).toBeUndefined(); editor.use(MyExt1); - expect(editor.foo).toEqual(foo1); + expect(instance.foo).toEqual(foo1); }); it('does not fail if no extensions supplied', () => { @@ -157,18 +272,18 @@ describe('Base editor', () => { }); it('is extensible with multiple extensions', () => { - expect(editor.foo).toBeUndefined(); - expect(editor.bar).toBeUndefined(); + expect(instance.foo).toBeUndefined(); + expect(instance.bar).toBeUndefined(); editor.use([MyExt1, MyExt2]); - expect(editor.foo).toEqual(foo1); - expect(editor.bar).toEqual(bar); + expect(instance.foo).toEqual(foo1); + expect(instance.bar).toEqual(bar); }); it('uses the last definition of a method in case of an overlap', () => { editor.use([MyExt1, MyExt2, MyExt3]); - expect(editor).toEqual( + expect(instance).toEqual( expect.objectContaining({ foo: foo2, bar, @@ -179,15 +294,47 @@ describe('Base editor', () => { it('correctly resolves references withing extensions', () => { const FunctionExt = { inst() { - return this.instance; + return this; }, mod() { - return this.model; + return this.getModel(); }, }; editor.use(FunctionExt); - expect(editor.inst()).toEqual(editor.instance); - expect(editor.mod()).toEqual(editor.model); + expect(instance.inst()).toEqual(editor.instances[0]); + }); + + describe('multiple instances', () => { + let inst1; + let inst2; + let editorEl1; + let editorEl2; + + beforeEach(() => { + setFixtures('<div id="editor1"></div><div id="editor2"></div>'); + editorEl1 = document.getElementById('editor1'); + editorEl2 = document.getElementById('editor2'); + inst1 = editor.createInstance({ el: editorEl1, blobPath: `foo-${blobPath}` }); + inst2 = editor.createInstance({ el: editorEl2, blobPath: `bar-${blobPath}` }); + }); + + afterEach(() => { + editor.dispose(); + editorEl1.remove(); + editorEl2.remove(); + }); + + it('extends all instances if no specific instance is passed', () => { + editor.use(MyExt1); + expect(inst1.foo).toEqual(foo1); + expect(inst2.foo).toEqual(foo1); + }); + + it('extends specific instance if it has been passed', () => { + editor.use(MyExt1, inst2); + expect(inst1.foo).toBeUndefined(); + expect(inst2.foo).toEqual(foo1); + }); }); }); diff --git a/spec/frontend/editor/editor_markdown_ext_spec.js b/spec/frontend/editor/editor_markdown_ext_spec.js index b0fabad8542..30ab29aad35 100644 --- a/spec/frontend/editor/editor_markdown_ext_spec.js +++ b/spec/frontend/editor/editor_markdown_ext_spec.js @@ -4,6 +4,7 @@ import EditorMarkdownExtension from '~/editor/editor_markdown_ext'; describe('Markdown Extension for Editor Lite', () => { let editor; + let instance; let editorEl; const firstLine = 'This is a'; const secondLine = 'multiline'; @@ -13,19 +14,19 @@ describe('Markdown Extension for Editor Lite', () => { const setSelection = (startLineNumber = 1, startColumn = 1, endLineNumber = 1, endColumn = 1) => { const selection = new Range(startLineNumber, startColumn, endLineNumber, endColumn); - editor.instance.setSelection(selection); + instance.setSelection(selection); }; const selectSecondString = () => setSelection(2, 1, 2, secondLine.length + 1); // select the whole second line const selectSecondAndThirdLines = () => setSelection(2, 1, 3, thirdLine.length + 1); // select second and third lines - const selectionToString = () => editor.instance.getSelection().toString(); - const positionToString = () => editor.instance.getPosition().toString(); + const selectionToString = () => instance.getSelection().toString(); + const positionToString = () => instance.getPosition().toString(); beforeEach(() => { setFixtures('<div id="editor" data-editor-loading></div>'); editorEl = document.getElementById('editor'); editor = new EditorLite(); - editor.createInstance({ + instance = editor.createInstance({ el: editorEl, blobPath: filePath, blobContent: text, @@ -34,17 +35,16 @@ describe('Markdown Extension for Editor Lite', () => { }); afterEach(() => { - editor.instance.dispose(); - editor.model.dispose(); + instance.dispose(); editorEl.remove(); }); describe('getSelectedText', () => { it('does not fail if there is no selection and returns the empty string', () => { - jest.spyOn(editor.instance, 'getSelection'); - const resText = editor.getSelectedText(); + jest.spyOn(instance, 'getSelection'); + const resText = instance.getSelectedText(); - expect(editor.instance.getSelection).toHaveBeenCalled(); + expect(instance.getSelection).toHaveBeenCalled(); expect(resText).toBe(''); }); @@ -56,7 +56,7 @@ describe('Markdown Extension for Editor Lite', () => { `('correctly returns selected text for $description', ({ selection, expectedString }) => { setSelection(...selection); - const resText = editor.getSelectedText(); + const resText = instance.getSelectedText(); expect(resText).toBe(expectedString); }); @@ -65,7 +65,7 @@ describe('Markdown Extension for Editor Lite', () => { selectSecondString(); const firstLineSelection = new Range(1, 1, 1, firstLine.length + 1); - const resText = editor.getSelectedText(firstLineSelection); + const resText = instance.getSelectedText(firstLineSelection); expect(resText).toBe(firstLine); }); @@ -76,25 +76,25 @@ describe('Markdown Extension for Editor Lite', () => { it('replaces selected text with the supplied one', () => { selectSecondString(); - editor.replaceSelectedText(expectedStr); + instance.replaceSelectedText(expectedStr); - expect(editor.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`); + expect(instance.getValue()).toBe(`${firstLine}\n${expectedStr}\n${thirdLine}`); }); it('prepends the supplied text if no text is selected', () => { - editor.replaceSelectedText(expectedStr); - expect(editor.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`); + instance.replaceSelectedText(expectedStr); + expect(instance.getValue()).toBe(`${expectedStr}${firstLine}\n${secondLine}\n${thirdLine}`); }); it('replaces selection with empty string if no text is supplied', () => { selectSecondString(); - editor.replaceSelectedText(); - expect(editor.getValue()).toBe(`${firstLine}\n\n${thirdLine}`); + instance.replaceSelectedText(); + expect(instance.getValue()).toBe(`${firstLine}\n\n${thirdLine}`); }); it('puts cursor at the end of the new string and collapses selection by default', () => { selectSecondString(); - editor.replaceSelectedText(expectedStr); + instance.replaceSelectedText(expectedStr); expect(positionToString()).toBe(`(2,${expectedStr.length + 1})`); expect(selectionToString()).toBe( @@ -106,7 +106,7 @@ describe('Markdown Extension for Editor Lite', () => { const select = 'url'; const complexReplacementString = `[${secondLine}](${select})`; selectSecondString(); - editor.replaceSelectedText(complexReplacementString, select); + instance.replaceSelectedText(complexReplacementString, select); expect(positionToString()).toBe(`(2,${complexReplacementString.length + 1})`); expect(selectionToString()).toBe(`[2,1 -> 2,${complexReplacementString.length + 1}]`); @@ -116,7 +116,7 @@ describe('Markdown Extension for Editor Lite', () => { describe('moveCursor', () => { const setPosition = endCol => { const currentPos = new Position(2, endCol); - editor.instance.setPosition(currentPos); + instance.setPosition(currentPos); }; it.each` @@ -136,9 +136,9 @@ describe('Markdown Extension for Editor Lite', () => { ({ startColumn, shift, endPosition }) => { setPosition(startColumn); if (Array.isArray(shift)) { - editor.moveCursor(...shift); + instance.moveCursor(...shift); } else { - editor.moveCursor(shift); + instance.moveCursor(shift); } expect(positionToString()).toBe(endPosition); }, @@ -153,18 +153,18 @@ describe('Markdown Extension for Editor Lite', () => { expect(selectionToString()).toBe(`[2,1 -> 3,${thirdLine.length + 1}]`); - editor.selectWithinSelection(toSelect, selectedText); + instance.selectWithinSelection(toSelect, selectedText); expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); }); it('does not fail when only `toSelect` is supplied and fetches the text from selection', () => { - jest.spyOn(editor, 'getSelectedText'); + jest.spyOn(instance, 'getSelectedText'); const toSelect = 'string'; selectSecondAndThirdLines(); - editor.selectWithinSelection(toSelect); + instance.selectWithinSelection(toSelect); - expect(editor.getSelectedText).toHaveBeenCalled(); + expect(instance.getSelectedText).toHaveBeenCalled(); expect(selectionToString()).toBe(`[3,1 -> 3,${toSelect.length + 1}]`); }); @@ -176,7 +176,7 @@ describe('Markdown Extension for Editor Lite', () => { expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); - editor.selectWithinSelection(); + instance.selectWithinSelection(); expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); @@ -190,12 +190,12 @@ describe('Markdown Extension for Editor Lite', () => { expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); - editor.selectWithinSelection(toSelect); + instance.selectWithinSelection(toSelect); expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); - editor.selectWithinSelection(); + instance.selectWithinSelection(); expect(positionToString()).toBe(expectedPos); expect(selectionToString()).toBe(expectedSelection); diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js index 9b49c8b8ab5..53c6d0835bc 100644 --- a/spec/frontend/emoji/emoji_spec.js +++ b/spec/frontend/emoji/emoji_spec.js @@ -55,11 +55,10 @@ const emojiFixtureMap = { describe('gl_emoji', () => { let mock; - const emojiData = getJSONFixture('emojis/emojis.json'); beforeEach(() => { mock = new MockAdapter(axios); - mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200, emojiData); + mock.onGet(`/-/emojis/${EMOJI_VERSION}/emojis.json`).reply(200); return initEmojiMap().catch(() => {}); }); diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index e7f5ee4bc4d..ebdc4923045 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,9 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import eventHub from '~/environments/event_hub'; import EnvironmentActions from '~/environments/components/environment_actions.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('EnvironmentActions Component', () => { let vm; @@ -17,7 +16,7 @@ describe('EnvironmentActions Component', () => { }); it('should render a dropdown button with 2 icons', () => { - expect(vm.find('.dropdown-new').findAll(Icon).length).toBe(2); + expect(vm.find('.dropdown-new').findAll(GlIcon).length).toBe(2); }); it('should render a dropdown button with aria-label description', () => { @@ -60,11 +59,7 @@ describe('EnvironmentActions Component', () => { }); it("should render a disabled action when it's not playable", () => { - expect(vm.find('.dropdown-menu li:last-child button').attributes('disabled')).toEqual( - 'disabled', - ); - - expect(vm.find('.dropdown-menu li:last-child button').classes('disabled')).toBe(true); + expect(vm.find('.dropdown-menu li:last-child gl-button-stub').props('disabled')).toBe(true); }); }); @@ -82,7 +77,7 @@ describe('EnvironmentActions Component', () => { scheduledAt: '2018-10-05T08:23:00Z', }; const findDropdownItem = action => { - const buttons = vm.findAll('.dropdown-menu li button'); + const buttons = vm.findAll('.dropdown-menu li gl-button-stub'); return buttons.filter(button => button.text().startsWith(action.name)).at(0); }; @@ -96,7 +91,7 @@ describe('EnvironmentActions Component', () => { eventHub.$on('postAction', emitSpy); jest.spyOn(window, 'confirm').mockImplementation(() => true); - findDropdownItem(scheduledJobAction).trigger('click'); + findDropdownItem(scheduledJobAction).vm.$emit('click'); expect(window.confirm).toHaveBeenCalled(); expect(emitSpy).toHaveBeenCalledWith({ endpoint: scheduledJobAction.playPath }); @@ -107,7 +102,7 @@ describe('EnvironmentActions Component', () => { eventHub.$on('postAction', emitSpy); jest.spyOn(window, 'confirm').mockImplementation(() => false); - findDropdownItem(scheduledJobAction).trigger('click'); + findDropdownItem(scheduledJobAction).vm.$emit('click'); expect(window.confirm).toHaveBeenCalled(); expect(emitSpy).not.toHaveBeenCalled(); diff --git a/spec/frontend/environments/environment_item_spec.js b/spec/frontend/environments/environment_item_spec.js index 5d374a162ab..1b429783821 100644 --- a/spec/frontend/environments/environment_item_spec.js +++ b/spec/frontend/environments/environment_item_spec.js @@ -3,7 +3,7 @@ import { format } from 'timeago.js'; import EnvironmentItem from '~/environments/components/environment_item.vue'; import PinComponent from '~/environments/components/environment_pin.vue'; import DeleteComponent from '~/environments/components/environment_delete.vue'; - +import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; import { environment, folder, tableData } from './mock_data'; describe('Environment item', () => { @@ -135,7 +135,7 @@ describe('Environment item', () => { }); describe('in the past', () => { - const pastDate = new Date(Date.now() - 100000); + const pastDate = new Date(differenceInMilliseconds(100000)); beforeEach(() => { factory({ propsData: { diff --git a/spec/frontend/environments/environment_monitoring_spec.js b/spec/frontend/environments/environment_monitoring_spec.js index d2129bd7b30..a73f49f1047 100644 --- a/spec/frontend/environments/environment_monitoring_spec.js +++ b/spec/frontend/environments/environment_monitoring_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import MonitoringComponent from '~/environments/components/environment_monitoring.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('Monitoring Component', () => { let wrapper; @@ -15,8 +15,8 @@ describe('Monitoring Component', () => { }); }; - const findIcons = () => wrapper.findAll(Icon); - const findIconsByName = name => findIcons().filter(icon => icon.props('name') === name); + const findButtons = () => wrapper.findAll(GlButton); + const findButtonsByIcon = icon => findButtons().filter(button => button.props('icon') === icon); beforeEach(() => { createWrapper(); @@ -30,7 +30,7 @@ describe('Monitoring Component', () => { it('should render a link to environment monitoring page', () => { expect(wrapper.attributes('href')).toEqual(monitoringUrl); - expect(findIconsByName('chart').length).toBe(1); + expect(findButtonsByIcon('chart').length).toBe(1); expect(wrapper.attributes('title')).toBe('Monitoring'); expect(wrapper.attributes('aria-label')).toBe('Monitoring'); }); diff --git a/spec/frontend/environments/environment_pin_spec.js b/spec/frontend/environments/environment_pin_spec.js index 486f6db7366..f48091adb44 100644 --- a/spec/frontend/environments/environment_pin_spec.js +++ b/spec/frontend/environments/environment_pin_spec.js @@ -1,6 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlButton, GlIcon } from '@gitlab/ui'; import eventHub from '~/environments/event_hub'; import PinComponent from '~/environments/components/environment_pin.vue'; @@ -32,12 +31,12 @@ describe('Pin Component', () => { }); it('should render the component with thumbtack icon', () => { - expect(wrapper.find(Icon).props('name')).toBe('thumbtack'); + expect(wrapper.find(GlIcon).props('name')).toBe('thumbtack'); }); it('should emit onPinClick when clicked', () => { const eventHubSpy = jest.spyOn(eventHub, '$emit'); - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); button.vm.$emit('click'); diff --git a/spec/frontend/environments/environment_rollback_spec.js b/spec/frontend/environments/environment_rollback_spec.js index f25e05f9cd8..fb62a096c3d 100644 --- a/spec/frontend/environments/environment_rollback_spec.js +++ b/spec/frontend/environments/environment_rollback_spec.js @@ -1,5 +1,5 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import eventHub from '~/environments/event_hub'; import RollbackComponent from '~/environments/components/environment_rollback.vue'; @@ -40,7 +40,7 @@ describe('Rollback Component', () => { }, }, }); - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); button.vm.$emit('click'); diff --git a/spec/frontend/environments/environment_terminal_button_spec.js b/spec/frontend/environments/environment_terminal_button_spec.js index 007fda2f2cc..274186fbbd6 100644 --- a/spec/frontend/environments/environment_terminal_button_spec.js +++ b/spec/frontend/environments/environment_terminal_button_spec.js @@ -22,7 +22,7 @@ describe('Stop Component', () => { }); it('should render a link to open a web terminal with the provided path', () => { - expect(wrapper.is('a')).toBe(true); + expect(wrapper.element.tagName).toBe('A'); expect(wrapper.attributes('title')).toBe('Terminal'); expect(wrapper.attributes('aria-label')).toBe('Terminal'); expect(wrapper.attributes('href')).toBe(terminalPath); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index d440bf73e15..fe32bf918dd 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -144,16 +144,16 @@ describe('Environment', () => { }); it('should open a closed folder', () => { - expect(wrapper.find('.folder-icon.ic-chevron-right').exists()).toBe(false); + expect(wrapper.find('.folder-icon[data-testid="chevron-right-icon"]').exists()).toBe(false); }); it('should close an opened folder', () => { - expect(wrapper.find('.folder-icon.ic-chevron-down').exists()).toBe(true); + expect(wrapper.find('.folder-icon[data-testid="chevron-down-icon"]').exists()).toBe(true); // close folder wrapper.find('.folder-name').trigger('click'); wrapper.vm.$nextTick(() => { - expect(wrapper.find('.folder-icon.ic-chevron-down').exists()).toBe(false); + expect(wrapper.find('.folder-icon[data-testid="chevron-down-icon"]').exists()).toBe(false); }); }); diff --git a/spec/frontend/error_tracking/components/error_tracking_list_spec.js b/spec/frontend/error_tracking/components/error_tracking_list_spec.js index bad70a31599..31f355ce6f1 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -163,7 +163,7 @@ describe('ErrorTrackingList', () => { it('each error in the list should have an action button set', () => { findErrorListRows().wrappers.forEach(row => { - expect(row.contains(ErrorTrackingActions)).toBe(true); + expect(row.find(ErrorTrackingActions).exists()).toBe(true); }); }); @@ -259,23 +259,15 @@ describe('ErrorTrackingList', () => { errorId: errorsList[0].id, status: 'ignored', }); - expect(actions.updateStatus).toHaveBeenCalledWith( - expect.anything(), - { - endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`, - status: 'ignored', - }, - undefined, - ); + expect(actions.updateStatus).toHaveBeenCalledWith(expect.anything(), { + endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`, + status: 'ignored', + }); }); it('calls an action to remove the item from the list', () => { findErrorActions().vm.$emit('update-issue-status', { errorId: '1', status: undefined }); - expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith( - expect.anything(), - '1', - undefined, - ); + expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith(expect.anything(), '1'); }); }); @@ -298,23 +290,15 @@ describe('ErrorTrackingList', () => { errorId: errorsList[0].id, status: 'resolved', }); - expect(actions.updateStatus).toHaveBeenCalledWith( - expect.anything(), - { - endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`, - status: 'resolved', - }, - undefined, - ); + expect(actions.updateStatus).toHaveBeenCalledWith(expect.anything(), { + endpoint: `/project/test/-/error_tracking/${errorsList[0].id}.json`, + status: 'resolved', + }); }); it('calls an action to remove the item from the list', () => { findErrorActions().vm.$emit('update-issue-status', { errorId: '1', status: undefined }); - expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith( - expect.anything(), - '1', - undefined, - ); + expect(actions.removeIgnoredResolvedErrors).toHaveBeenCalledWith(expect.anything(), '1'); }); }); @@ -443,7 +427,6 @@ describe('ErrorTrackingList', () => { expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith( expect.anything(), 'previousCursor', - undefined, ); }); }); @@ -462,7 +445,6 @@ describe('ErrorTrackingList', () => { expect(actions.fetchPaginatedResults).toHaveBeenLastCalledWith( expect.anything(), 'nextCursor', - undefined, ); }); }); diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js index df7bff201f1..6df25ad6819 100644 --- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js +++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js @@ -1,10 +1,9 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSprintf } from '@gitlab/ui'; +import { GlSprintf, GlIcon } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; import StackTraceEntry from '~/error_tracking/components/stacktrace_entry.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('Stacktrace Entry', () => { let wrapper; @@ -39,7 +38,7 @@ describe('Stacktrace Entry', () => { mountComponent({ lines }); expect(wrapper.find(StackTraceEntry).exists()).toBe(true); expect(wrapper.find(ClipboardButton).exists()).toBe(true); - expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); expect(wrapper.find(FileIcon).exists()).toBe(true); expect(wrapper.find('table').exists()).toBe(false); }); @@ -57,7 +56,7 @@ describe('Stacktrace Entry', () => { it('should hide collapse icon and render error fn name and error line when there is no code block', () => { const extraInfo = { errorLine: 34, errorFn: 'errorFn', errorColumn: 77 }; mountComponent({ expanded: false, lines: [], ...extraInfo }); - expect(wrapper.find(Icon).exists()).toBe(false); + expect(wrapper.find(GlIcon).exists()).toBe(false); expect(trimText(findFileHeaderContent())).toContain( `in ${extraInfo.errorFn} at line ${extraInfo.errorLine}:${extraInfo.errorColumn}`, ); diff --git a/spec/frontend/fixtures/search.rb b/spec/frontend/fixtures/search.rb index fcd68662acc..40f613a9422 100644 --- a/spec/frontend/fixtures/search.rb +++ b/spec/frontend/fixtures/search.rb @@ -7,10 +7,16 @@ RSpec.describe SearchController, '(JavaScript fixtures)', type: :controller do render_views + let_it_be(:user) { create(:admin) } + before(:all) do clean_frontend_fixtures('search/') end + before do + sign_in(user) + end + it 'search/show.html' do get :show diff --git a/spec/frontend/fixtures/static/ajax_loading_spinner.html b/spec/frontend/fixtures/static/ajax_loading_spinner.html deleted file mode 100644 index 0e1ebb32b1c..00000000000 --- a/spec/frontend/fixtures/static/ajax_loading_spinner.html +++ /dev/null @@ -1,3 +0,0 @@ -<a class="js-ajax-loading-spinner" data-remote href="http://goesnowhere.nothing/whereami"> -<i class="fa fa-trash-o"></i> -</a> diff --git a/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html b/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html new file mode 100644 index 00000000000..3db9bafcb9f --- /dev/null +++ b/spec/frontend/fixtures/static/deprecated_jquery_dropdown.html @@ -0,0 +1,39 @@ +<div> + <div class="dropdown inline"> + <button + class="dropdown-menu-toggle" + data-toggle="dropdown" + id="js-project-dropdown" + type="button" + > + <div class="dropdown-toggle-text"> + Projects + </div> + <i class="fa fa-chevron-down dropdown-toggle-caret js-projects-dropdown-toggle"></i> + </button> + <div class="dropdown-menu dropdown-select dropdown-menu-selectable"> + <div class="dropdown-title gl-display-flex gl-align-items-center"> + <span class="gl-ml-auto">Go to project</span> + <button + aria-label="Close" + type="button" + class="btn dropdown-title-button dropdown-menu-close gl-ml-auto btn-default btn-sm gl-button btn-default-tertiary btn-icon" + > + <svg data-testid="close-icon" class="gl-icon s16 dropdown-menu-close-icon"> + <use + href="/assets/icons-795a2ef2fd636a0538bbef3b8d2787dd90927b42d7617fdda8620930016b333d.svg#close" + ></use> + </svg> + </button> + </div> + <div class="dropdown-input"> + <input class="dropdown-input-field" placeholder="Filter results" type="search" /> + <i class="fa fa-search dropdown-input-search"></i> + </div> + <div class="dropdown-content"></div> + <div class="dropdown-loading"> + <i class="fa fa-spinner fa-spin"></i> + </div> + </div> + </div> +</div> diff --git a/spec/frontend/fixtures/static/gl_dropdown.html b/spec/frontend/fixtures/static/gl_dropdown.html deleted file mode 100644 index 08f6738414e..00000000000 --- a/spec/frontend/fixtures/static/gl_dropdown.html +++ /dev/null @@ -1,26 +0,0 @@ -<div> -<div class="dropdown inline"> -<button class="dropdown-menu-toggle" data-toggle="dropdown" id="js-project-dropdown" type="button"> -<div class="dropdown-toggle-text"> -Projects -</div> -<i class="fa fa-chevron-down dropdown-toggle-caret js-projects-dropdown-toggle"></i> -</button> -<div class="dropdown-menu dropdown-select dropdown-menu-selectable"> -<div class="dropdown-title"> -<span>Go to project</span> -<button aria="{:label=>"Close"}" class="dropdown-title-button dropdown-menu-close"> -<i class="fa fa-times dropdown-menu-close-icon"></i> -</button> -</div> -<div class="dropdown-input"> -<input class="dropdown-input-field" placeholder="Filter results" type="search"> -<i class="fa fa-search dropdown-input-search"></i> -</div> -<div class="dropdown-content"></div> -<div class="dropdown-loading"> -<i class="fa fa-spinner fa-spin"></i> -</div> -</div> -</div> -</div> diff --git a/spec/frontend/fixtures/u2f.rb b/spec/frontend/fixtures/u2f.rb index be3874d7c42..a6a8ba7318b 100644 --- a/spec/frontend/fixtures/u2f.rb +++ b/spec/frontend/fixtures/u2f.rb @@ -11,6 +11,10 @@ RSpec.context 'U2F' do clean_frontend_fixtures('u2f/') end + before do + stub_feature_flags(webauthn: false) + end + describe SessionsController, '(JavaScript fixtures)', type: :controller do include DeviseHelpers diff --git a/spec/frontend/fixtures/webauthn.rb b/spec/frontend/fixtures/webauthn.rb new file mode 100644 index 00000000000..b195fee76f0 --- /dev/null +++ b/spec/frontend/fixtures/webauthn.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.context 'WebAuthn' do + include JavaScriptFixturesHelpers + + let(:user) { create(:user, :two_factor_via_webauthn, otp_secret: 'otpsecret:coolkids') } + + before(:all) do + clean_frontend_fixtures('webauthn/') + end + + describe SessionsController, '(JavaScript fixtures)', type: :controller do + include DeviseHelpers + + render_views + + before do + set_devise_mapping(context: @request) + end + + it 'webauthn/authenticate.html' do + allow(controller).to receive(:find_user).and_return(user) + post :create, params: { user: { login: user.username, password: user.password } } + + expect(response).to be_successful + end + end + + describe Profiles::TwoFactorAuthsController, '(JavaScript fixtures)', type: :controller do + render_views + + before do + sign_in(user) + allow_next_instance_of(Profiles::TwoFactorAuthsController) do |instance| + allow(instance).to receive(:build_qr_code).and_return('qrcode:blackandwhitesquares') + end + end + + it 'webauthn/register.html' do + get :show + + expect(response).to be_successful + end + end +end diff --git a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js index 204bbfb9c2f..c5155315bb9 100644 --- a/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js +++ b/spec/frontend/frequent_items/components/frequent_items_search_input_spec.js @@ -69,8 +69,8 @@ describe('FrequentItemsSearchInputComponent', () => { describe('template', () => { it('should render component element', () => { expect(wrapper.classes()).toContain('search-input-container'); - expect(wrapper.contains('input.form-control')).toBe(true); - expect(wrapper.contains('.search-icon')).toBe(true); + expect(wrapper.find('input.form-control').exists()).toBe(true); + expect(wrapper.find('.search-icon').exists()).toBe(true); expect(wrapper.find('input.form-control').attributes('placeholder')).toBe( 'Search your projects', ); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 869347128e5..6c40b1ba3a7 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -1,11 +1,9 @@ /* eslint no-param-reassign: "off" */ import $ from 'jquery'; +import '~/lib/utils/jquery_at_who'; import GfmAutoComplete, { membersBeforeSave } from 'ee_else_ce/gfm_auto_complete'; -import 'jquery.caret'; -import '@gitlab/at.js'; - import { TEST_HOST } from 'helpers/test_constants'; import { getJSONFixture } from 'helpers/fixtures'; @@ -121,7 +119,7 @@ describe('GfmAutoComplete', () => { const defaultMatcher = (context, flag, subtext) => gfmAutoCompleteCallbacks.matcher.call(context, flag, subtext); - const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$']; + const flagsUseDefaultMatcher = ['@', '#', '!', '~', '%', '$', '+']; const otherFlags = ['/', ':']; const flags = flagsUseDefaultMatcher.concat(otherFlags); @@ -155,7 +153,6 @@ describe('GfmAutoComplete', () => { 'я', '.', "'", - '+', '-', '_', ]; diff --git a/spec/frontend/gpg_badges_spec.js b/spec/frontend/gpg_badges_spec.js index 809cc5c88e2..644b687aa19 100644 --- a/spec/frontend/gpg_badges_spec.js +++ b/spec/frontend/gpg_badges_spec.js @@ -68,7 +68,7 @@ describe('GpgBadges', () => { GpgBadges.fetch() .then(() => { expect(document.querySelector('.js-loading-gpg-badge:empty')).toBe(null); - const spinners = document.querySelectorAll('.js-loading-gpg-badge i.fa.fa-spinner.fa-spin'); + const spinners = document.querySelectorAll('.js-loading-gpg-badge span.gl-spinner'); expect(spinners.length).toBe(1); done(); diff --git a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap index 0e16b726c4b..0befe1aa192 100644 --- a/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap +++ b/spec/frontend/grafana_integration/components/__snapshots__/grafana_integration_spec.js.snap @@ -83,7 +83,7 @@ exports[`grafana integration component default state to match the default snapsh More information - <icon-stub + <gl-icon-stub class="vertical-align-middle" name="external-link" size="16" diff --git a/spec/frontend/groups/components/invite_members_banner_spec.js b/spec/frontend/groups/components/invite_members_banner_spec.js new file mode 100644 index 00000000000..95003b211fd --- /dev/null +++ b/spec/frontend/groups/components/invite_members_banner_spec.js @@ -0,0 +1,144 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBanner } from '@gitlab/ui'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { setCookie, parseBoolean } from '~/lib/utils/common_utils'; +import InviteMembersBanner from '~/groups/components/invite_members_banner.vue'; + +jest.mock('~/lib/utils/common_utils'); + +const isDismissedKey = 'invite_99_1'; +const title = 'Collaborate with your team'; +const body = + "We noticed that you haven't invited anyone to this group. Invite your colleagues so you can discuss issues, collaborate on merge requests, and share your knowledge"; +const svgPath = '/illustrations/background'; +const inviteMembersPath = 'groups/members'; +const buttonText = 'Invite your colleagues'; +const trackLabel = 'invite_members_banner'; + +const createComponent = (stubs = {}) => { + return shallowMount(InviteMembersBanner, { + provide: { + svgPath, + inviteMembersPath, + isDismissedKey, + trackLabel, + }, + stubs, + }); +}; + +describe('InviteMembersBanner', () => { + let wrapper; + let trackingSpy; + + beforeEach(() => { + document.body.dataset.page = 'any:page'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + unmockTracking(); + }); + + describe('tracking', () => { + beforeEach(() => { + wrapper = createComponent({ GlBanner }); + }); + + const trackCategory = undefined; + const displayEvent = 'invite_members_banner_displayed'; + const buttonClickEvent = 'invite_members_banner_button_clicked'; + const dismissEvent = 'invite_members_banner_dismissed'; + + it('sends the displayEvent when the banner is displayed', () => { + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, displayEvent, { + label: trackLabel, + }); + }); + + it('sets the button attributes for the buttonClickEvent', () => { + const button = wrapper.find(`[href='${wrapper.vm.inviteMembersPath}']`); + + expect(button.attributes()).toMatchObject({ + 'data-track-event': buttonClickEvent, + 'data-track-label': trackLabel, + }); + }); + + it('sends the dismissEvent when the banner is dismissed', () => { + wrapper.find(GlBanner).vm.$emit('close'); + + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, dismissEvent, { + label: trackLabel, + }); + }); + }); + + describe('rendering', () => { + const findBanner = () => { + return wrapper.find(GlBanner); + }; + + beforeEach(() => { + wrapper = createComponent(); + }); + + it('uses the svgPath for the banner svgpath', () => { + expect(findBanner().attributes('svgpath')).toBe(svgPath); + }); + + it('uses the title from options for title', () => { + expect(findBanner().attributes('title')).toBe(title); + }); + + it('includes the body text from options', () => { + expect(findBanner().html()).toContain(body); + }); + + it('uses the button_text text from options for buttontext', () => { + expect(findBanner().attributes('buttontext')).toBe(buttonText); + }); + + it('uses the href from inviteMembersPath for buttonlink', () => { + expect(findBanner().attributes('buttonlink')).toBe(inviteMembersPath); + }); + }); + + describe('dismissing', () => { + const findButton = () => { + return wrapper.find('button'); + }; + + beforeEach(() => { + wrapper = createComponent({ GlBanner }); + + findButton().trigger('click'); + }); + + it('sets iDismissed to true', () => { + expect(wrapper.vm.isDismissed).toBe(true); + }); + + it('sets the cookie with the isDismissedKey', () => { + expect(setCookie).toHaveBeenCalledWith(isDismissedKey, true); + }); + }); + + describe('when a dismiss cookie exists', () => { + beforeEach(() => { + parseBoolean.mockReturnValue(true); + + wrapper = createComponent({ GlBanner }); + }); + + it('sets isDismissed to true', () => { + expect(wrapper.vm.isDismissed).toBe(true); + }); + + it('does not render the banner', () => { + expect(wrapper.contains(GlBanner)).toBe(false); + }); + }); +}); diff --git a/spec/frontend/groups/components/item_actions_spec.js b/spec/frontend/groups/components/item_actions_spec.js index c0dc1a816e6..f5df8c180d5 100644 --- a/spec/frontend/groups/components/item_actions_spec.js +++ b/spec/frontend/groups/components/item_actions_spec.js @@ -57,8 +57,8 @@ describe('ItemActionsComponent', () => { expect(editBtn.getAttribute('href')).toBe(group.editPath); expect(editBtn.getAttribute('aria-label')).toBe('Edit group'); expect(editBtn.dataset.originalTitle).toBe('Edit group'); - expect(editBtn.querySelectorAll('svg use').length).not.toBe(0); - expect(editBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#settings'); + expect(editBtn.querySelectorAll('svg').length).not.toBe(0); + expect(editBtn.querySelector('svg').getAttribute('data-testid')).toBe('settings-icon'); newVm.$destroy(); }); @@ -75,8 +75,8 @@ describe('ItemActionsComponent', () => { expect(leaveBtn.getAttribute('href')).toBe(group.leavePath); expect(leaveBtn.getAttribute('aria-label')).toBe('Leave this group'); expect(leaveBtn.dataset.originalTitle).toBe('Leave this group'); - expect(leaveBtn.querySelectorAll('svg use').length).not.toBe(0); - expect(leaveBtn.querySelector('svg use').getAttribute('xlink:href')).toContain('#leave'); + expect(leaveBtn.querySelectorAll('svg').length).not.toBe(0); + expect(leaveBtn.querySelector('svg').getAttribute('data-testid')).toBe('leave-icon'); newVm.$destroy(); }); diff --git a/spec/frontend/groups/components/item_caret_spec.js b/spec/frontend/groups/components/item_caret_spec.js index bfe27be9b51..4ff7482414c 100644 --- a/spec/frontend/groups/components/item_caret_spec.js +++ b/spec/frontend/groups/components/item_caret_spec.js @@ -27,12 +27,12 @@ describe('ItemCaretComponent', () => { it('should render caret down icon if `isGroupOpen` prop is `true`', () => { vm = createComponent(true); - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-down'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-down-icon'); }); it('should render caret right icon if `isGroupOpen` prop is `false`', () => { vm = createComponent(); - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('angle-right'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('angle-right-icon'); }); }); }); diff --git a/spec/frontend/groups/components/item_stats_value_spec.js b/spec/frontend/groups/components/item_stats_value_spec.js index da6f145fa19..11246390444 100644 --- a/spec/frontend/groups/components/item_stats_value_spec.js +++ b/spec/frontend/groups/components/item_stats_value_spec.js @@ -72,7 +72,7 @@ describe('ItemStatsValueComponent', () => { }); it('renders element icon correctly', () => { - expect(vm.$el.querySelector('svg use').getAttribute('xlink:href')).toContain('folder'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-icon'); }); it('renders value count correctly', () => { diff --git a/spec/frontend/groups/components/item_type_icon_spec.js b/spec/frontend/groups/components/item_type_icon_spec.js index 251b5b5ff4c..477c413ddcd 100644 --- a/spec/frontend/groups/components/item_type_icon_spec.js +++ b/spec/frontend/groups/components/item_type_icon_spec.js @@ -27,12 +27,12 @@ describe('ItemTypeIconComponent', () => { vm = createComponent(ITEM_TYPE.GROUP, true); - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder-open'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-open-icon'); vm.$destroy(); vm = createComponent(ITEM_TYPE.GROUP); - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('folder'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('folder-o-icon'); vm.$destroy(); }); @@ -41,12 +41,12 @@ describe('ItemTypeIconComponent', () => { vm = createComponent(ITEM_TYPE.PROJECT); - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).toContain('bookmark'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).toBe('bookmark-icon'); vm.$destroy(); vm = createComponent(ITEM_TYPE.GROUP); - expect(vm.$el.querySelector('use').getAttribute('xlink:href')).not.toContain('bookmark'); + expect(vm.$el.querySelector('svg').getAttribute('data-testid')).not.toBe('bookmark-icon'); vm.$destroy(); }); }); diff --git a/spec/frontend/groups/members/index_spec.js b/spec/frontend/groups/members/index_spec.js new file mode 100644 index 00000000000..70fce0d60fb --- /dev/null +++ b/spec/frontend/groups/members/index_spec.js @@ -0,0 +1,66 @@ +import { createWrapper } from '@vue/test-utils'; +import initGroupMembersApp from '~/groups/members'; +import GroupMembersApp from '~/groups/members/components/app.vue'; +import { membersJsonString, membersParsed } from './mock_data'; + +describe('initGroupMembersApp', () => { + let el; + let vm; + let wrapper; + + const setup = () => { + vm = initGroupMembersApp(el); + wrapper = createWrapper(vm); + }; + + beforeEach(() => { + el = document.createElement('div'); + el.setAttribute('data-members', membersJsonString); + el.setAttribute('data-group-id', '234'); + + window.gon = { current_user_id: 123 }; + + document.body.appendChild(el); + }); + + afterEach(() => { + document.body.innerHTML = ''; + el = null; + + wrapper.destroy(); + wrapper = null; + }); + + it('renders `GroupMembersApp`', () => { + setup(); + + expect(wrapper.find(GroupMembersApp).exists()).toBe(true); + }); + + it('sets `currentUserId` in Vuex store', () => { + setup(); + + expect(vm.$store.state.currentUserId).toBe(123); + }); + + describe('when `gon.current_user_id` is not set (user is not logged in)', () => { + it('sets `currentUserId` as `null` in Vuex store', () => { + window.gon = {}; + setup(); + + expect(vm.$store.state.currentUserId).toBeNull(); + }); + }); + + it('parses and sets `data-group-id` as `sourceId` in Vuex store', () => { + setup(); + + expect(vm.$store.state.sourceId).toBe(234); + }); + + it('parses and sets `members` in Vuex store', () => { + setup(); + + expect(vm.$store.state.members).toEqual(membersParsed); + }); +}); diff --git a/spec/frontend/groups/members/mock_data.js b/spec/frontend/groups/members/mock_data.js new file mode 100644 index 00000000000..b84c9c6d446 --- /dev/null +++ b/spec/frontend/groups/members/mock_data.js @@ -0,0 +1,33 @@ +export const membersJsonString = + '[{"requested_at":null,"can_update":true,"can_remove":true,"can_override":false,"access_level":{"integer_value":50,"string_value":"Owner"},"source":{"id":323,"name":"My group / my subgroup","web_url":"http://127.0.0.1:3000/groups/my-group/my-subgroup"},"user":{"id":1,"name":"Administrator","username":"root","web_url":"http://127.0.0.1:3000/root","avatar_url":"https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80\u0026d=identicon","blocked":false,"two_factor_enabled":false},"id":524,"created_at":"2020-08-21T21:33:27.631Z","expires_at":null,"using_license":false,"group_sso":false,"group_managed_account":false}]'; + +export const membersParsed = [ + { + requestedAt: null, + canUpdate: true, + canRemove: true, + canOverride: false, + accessLevel: { integerValue: 50, stringValue: 'Owner' }, + source: { + id: 323, + name: 'My group / my subgroup', + webUrl: 'http://127.0.0.1:3000/groups/my-group/my-subgroup', + }, + user: { + id: 1, + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + avatarUrl: + 'https://www.gravatar.com/avatar/4816142ef496f956a277bedf1a40607b?s=80&d=identicon', + blocked: false, + twoFactorEnabled: false, + }, + id: 524, + createdAt: '2020-08-21T21:33:27.631Z', + expiresAt: null, + usingLicense: false, + groupSso: false, + groupManagedAccount: false, + }, +]; diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 59a8ca2ed23..27305abfafa 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -4,7 +4,7 @@ import initTodoToggle, { initNavUserDropdownTracking } from '~/header'; describe('Header', () => { describe('Todos notification', () => { - const todosPendingCount = '.todos-count'; + const todosPendingCount = '.js-todos-count'; const fixtureTemplate = 'issues/open-issue.html'; function isTodosCountHidden() { diff --git a/spec/frontend/helpers/dom_events_helper.js b/spec/frontend/helpers/dom_events_helper.js index 139e0813397..423e5c58bb4 100644 --- a/spec/frontend/helpers/dom_events_helper.js +++ b/spec/frontend/helpers/dom_events_helper.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const triggerDOMEvent = type => { window.document.dispatchEvent( new Event(type, { diff --git a/spec/frontend/helpers/fake_request_animation_frame.js b/spec/frontend/helpers/fake_request_animation_frame.js index b01ae5b7c5f..f6fc29df4dc 100644 --- a/spec/frontend/helpers/fake_request_animation_frame.js +++ b/spec/frontend/helpers/fake_request_animation_frame.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const useFakeRequestAnimationFrame = () => { let orig; diff --git a/spec/frontend/helpers/jest_helpers.js b/spec/frontend/helpers/jest_helpers.js index 4a150be9935..0b623e0a59b 100644 --- a/spec/frontend/helpers/jest_helpers.js +++ b/spec/frontend/helpers/jest_helpers.js @@ -1,5 +1,3 @@ -/* eslint-disable import/prefer-default-export */ - /* @module diff --git a/spec/frontend/helpers/local_storage_helper.js b/spec/frontend/helpers/local_storage_helper.js index a66c31d1353..cd39b660bfd 100644 --- a/spec/frontend/helpers/local_storage_helper.js +++ b/spec/frontend/helpers/local_storage_helper.js @@ -10,7 +10,7 @@ */ const useLocalStorage = fn => { const origLocalStorage = window.localStorage; - let currentLocalStorage; + let currentLocalStorage = origLocalStorage; Object.defineProperty(window, 'localStorage', { get: () => currentLocalStorage, diff --git a/spec/frontend/helpers/local_storage_helper_spec.js b/spec/frontend/helpers/local_storage_helper_spec.js index 18aec0f329a..6b44ea3a4c3 100644 --- a/spec/frontend/helpers/local_storage_helper_spec.js +++ b/spec/frontend/helpers/local_storage_helper_spec.js @@ -1,8 +1,15 @@ import { useLocalStorageSpy } from './local_storage_helper'; -useLocalStorageSpy(); +describe('block before helper is installed', () => { + it('should leave original localStorage intact', () => { + expect(localStorage.getItem).toEqual(expect.any(Function)); + expect(jest.isMockFunction(localStorage.getItem)).toBe(false); + }); +}); describe('localStorage helper', () => { + useLocalStorageSpy(); + it('mocks localStorage but works exactly like original localStorage', () => { localStorage.setItem('test', 'testing'); localStorage.setItem('test2', 'testing'); diff --git a/spec/frontend/helpers/locale_helper.js b/spec/frontend/helpers/locale_helper.js index 80047b06003..283d9bc14c9 100644 --- a/spec/frontend/helpers/locale_helper.js +++ b/spec/frontend/helpers/locale_helper.js @@ -1,5 +1,3 @@ -/* eslint-disable import/prefer-default-export */ - export const setLanguage = languageCode => { const htmlElement = document.querySelector('html'); diff --git a/spec/frontend/helpers/mock_apollo_helper.js b/spec/frontend/helpers/mock_apollo_helper.js new file mode 100644 index 00000000000..8a5a160231c --- /dev/null +++ b/spec/frontend/helpers/mock_apollo_helper.js @@ -0,0 +1,23 @@ +import { InMemoryCache } from 'apollo-cache-inmemory'; +import { createMockClient } from 'mock-apollo-client'; +import VueApollo from 'vue-apollo'; + +export default (handlers = []) => { + const fragmentMatcher = { match: () => true }; + const cache = new InMemoryCache({ + fragmentMatcher, + addTypename: false, + }); + + const mockClient = createMockClient({ cache }); + + if (Array.isArray(handlers)) { + handlers.forEach(([query, value]) => mockClient.setRequestHandler(query, value)); + } else { + throw new Error('You should pass an array of handlers to mock Apollo client'); + } + + const apolloProvider = new VueApollo({ defaultClient: mockClient }); + + return apolloProvider; +}; diff --git a/spec/frontend/helpers/mock_dom_observer.js b/spec/frontend/helpers/mock_dom_observer.js index 7aac51f6264..1b93b81535d 100644 --- a/spec/frontend/helpers/mock_dom_observer.js +++ b/spec/frontend/helpers/mock_dom_observer.js @@ -84,7 +84,9 @@ const useMockObserver = (key, createMock) => { mockObserver.$_triggerObserve(...args); }; - return { trigger }; + const observersCount = () => mockObserver.$_observers.length; + + return { trigger, observersCount }; }; export const useMockIntersectionObserver = () => diff --git a/spec/frontend/helpers/startup_css_helper_spec.js b/spec/frontend/helpers/startup_css_helper_spec.js new file mode 100644 index 00000000000..7b83f0aefca --- /dev/null +++ b/spec/frontend/helpers/startup_css_helper_spec.js @@ -0,0 +1,65 @@ +import { waitForCSSLoaded } from '../../../app/assets/javascripts/helpers/startup_css_helper'; + +describe('waitForCSSLoaded', () => { + let mockedCallback; + + beforeEach(() => { + mockedCallback = jest.fn(); + }); + + describe('Promise-like api', () => { + it('can be used with a callback', async () => { + await waitForCSSLoaded(mockedCallback); + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + + it('can be used as a promise', async () => { + await waitForCSSLoaded().then(mockedCallback); + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('with startup css disabled', () => { + gon.features = { + startupCss: false, + }; + + it('should invoke the action right away', async () => { + const events = waitForCSSLoaded(mockedCallback); + await events; + + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + }); + + describe('with startup css enabled', () => { + gon.features = { + startupCss: true, + }; + + it('should dispatch CSSLoaded when the assets are cached or already loaded', async () => { + setFixtures(` + <link href="one.css" data-startupcss="loaded"> + <link href="two.css" data-startupcss="loaded"> + `); + await waitForCSSLoaded(mockedCallback); + + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + + it('should wait to call CssLoaded until the assets are loaded', async () => { + setFixtures(` + <link href="one.css" data-startupcss="loading"> + <link href="two.css" data-startupcss="loading"> + `); + const events = waitForCSSLoaded(mockedCallback); + document + .querySelectorAll('[data-startupcss="loading"]') + .forEach(elem => elem.setAttribute('data-startupcss', 'loaded')); + document.dispatchEvent(new CustomEvent('CSSStartupLinkLoaded')); + await events; + + expect(mockedCallback).toHaveBeenCalledTimes(1); + }); + }); +}); diff --git a/spec/frontend/ide/commit_icon_spec.js b/spec/frontend/ide/commit_icon_spec.js index e4a7394b089..0dfcae00298 100644 --- a/spec/frontend/ide/commit_icon_spec.js +++ b/spec/frontend/ide/commit_icon_spec.js @@ -7,7 +7,6 @@ const createFile = (name = 'name', id = name, type = '', parent = null) => id, type, icon: 'icon', - url: 'url', name, path: parent ? `${parent.path}/${name}` : name, parentPath: parent ? parent.path : '', diff --git a/spec/frontend/ide/components/branches/item_spec.js b/spec/frontend/ide/components/branches/item_spec.js index d8175025755..f1aa9187a8d 100644 --- a/spec/frontend/ide/components/branches/item_spec.js +++ b/spec/frontend/ide/components/branches/item_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import { createStore } from '~/ide/stores'; import { createRouter } from '~/ide/ide_router'; import Item from '~/ide/components/branches/item.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import Timeago from '~/vue_shared/components/time_ago_tooltip.vue'; import { projectData } from '../../mock_data'; @@ -45,7 +45,7 @@ describe('IDE branch item', () => { it('renders branch name and timeago', () => { expect(wrapper.text()).toContain(TEST_BRANCH.name); expect(wrapper.find(Timeago).props('time')).toBe(TEST_BRANCH.committedDate); - expect(wrapper.find(Icon).exists()).toBe(false); + expect(wrapper.find(GlIcon).exists()).toBe(false); }); it('renders link to branch', () => { @@ -60,6 +60,6 @@ describe('IDE branch item', () => { it('renders icon if is not active', () => { createComponent({ isActive: true }); - expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); }); }); diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index 9245cefc183..56667d6b03d 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -1,10 +1,13 @@ import Vue from 'vue'; +import { getByText } from '@testing-library/dom'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { projectData } from 'jest/ide/mock_data'; import waitForPromises from 'helpers/wait_for_promises'; import { createStore } from '~/ide/stores'; +import consts from '~/ide/stores/modules/commit/constants'; import CommitForm from '~/ide/components/commit_sidebar/form.vue'; import { leftSidebarViews } from '~/ide/constants'; +import { createCodeownersCommitError, createUnexpectedCommitError } from '~/ide/lib/errors'; describe('IDE commit form', () => { const Component = Vue.extend(CommitForm); @@ -259,21 +262,47 @@ describe('IDE commit form', () => { }); }); - it('opens new branch modal if commitChanges throws an error', () => { - vm.commitChanges.mockRejectedValue({ success: false }); + it.each` + createError | props + ${() => createCodeownersCommitError('test message')} | ${{ actionPrimary: { text: 'Create new branch' } }} + ${createUnexpectedCommitError} | ${{ actionPrimary: null }} + `('opens error modal if commitError with $error', async ({ createError, props }) => { + jest.spyOn(vm.$refs.commitErrorModal, 'show'); - jest.spyOn(vm.$refs.createBranchModal, 'show').mockImplementation(); + const error = createError(); + store.state.commit.commitError = error; - return vm - .$nextTick() - .then(() => { - vm.$el.querySelector('.btn-success').click(); + await vm.$nextTick(); - return vm.$nextTick(); - }) - .then(() => { - expect(vm.$refs.createBranchModal.show).toHaveBeenCalled(); - }); + expect(vm.$refs.commitErrorModal.show).toHaveBeenCalled(); + expect(vm.$refs.commitErrorModal).toMatchObject({ + actionCancel: { text: 'Cancel' }, + ...props, + }); + // Because of the legacy 'mountComponent' approach here, the only way to + // test the text of the modal is by viewing the content of the modal added to the document. + expect(document.body).toHaveText(error.messageHTML); + }); + }); + + describe('with error modal with primary', () => { + beforeEach(() => { + jest.spyOn(vm.$store, 'dispatch').mockReturnValue(Promise.resolve()); + }); + + it('updates commit action and commits', async () => { + store.state.commit.commitError = createCodeownersCommitError('test message'); + + await vm.$nextTick(); + + getByText(document.body, 'Create new branch').click(); + + await waitForPromises(); + + expect(vm.$store.dispatch.mock.calls).toEqual([ + ['commit/updateCommitAction', consts.COMMIT_TO_NEW_BRANCH], + ['commit/commitChanges', undefined], + ]); }); }); }); diff --git a/spec/frontend/ide/components/error_message_spec.js b/spec/frontend/ide/components/error_message_spec.js index 3a4dcc5873d..8b7e7da3b51 100644 --- a/spec/frontend/ide/components/error_message_spec.js +++ b/spec/frontend/ide/components/error_message_spec.js @@ -51,7 +51,7 @@ describe('IDE error message component', () => { createComponent(); findDismissButton().trigger('click'); - expect(setErrorMessageMock).toHaveBeenCalledWith(expect.any(Object), null, undefined); + expect(setErrorMessageMock).toHaveBeenCalledWith(expect.any(Object), null); }); describe('with action', () => { diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js index 4bd27d23f76..2a106ad37c0 100644 --- a/spec/frontend/ide/components/file_row_extra_spec.js +++ b/spec/frontend/ide/components/file_row_extra_spec.js @@ -153,14 +153,14 @@ describe('IDE extra file row component', () => { describe('merge request icon', () => { it('hides when not a merge request change', () => { - expect(vm.$el.querySelector('.ic-git-merge')).toBe(null); + expect(vm.$el.querySelector('[data-testid="git-merge-icon"]')).toBe(null); }); it('shows when a merge request change', done => { vm.file.mrChange = true; vm.$nextTick(() => { - expect(vm.$el.querySelector('.ic-git-merge')).not.toBe(null); + expect(vm.$el.querySelector('[data-testid="git-merge-icon"]')).not.toBe(null); done(); }); diff --git a/spec/frontend/ide/components/ide_status_list_spec.js b/spec/frontend/ide/components/ide_status_list_spec.js index fed61233e55..bb8165d1a52 100644 --- a/spec/frontend/ide/components/ide_status_list_spec.js +++ b/spec/frontend/ide/components/ide_status_list_spec.js @@ -75,7 +75,8 @@ describe('ide/components/ide_status_list', () => { describe('with binary file', () => { beforeEach(() => { - activeFile.binary = true; + activeFile.name = 'abc.dat'; + activeFile.content = '🐱'; // non-ascii binary content createComponent(); }); diff --git a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap index dbfacb98813..a65d9e6f78b 100644 --- a/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap +++ b/spec/frontend/ide/components/jobs/__snapshots__/stage_spec.js.snap @@ -34,7 +34,7 @@ exports[`IDE pipeline stage renders stage details & icon 1`] = ` </span> </div> - <icon-stub + <gl-icon-stub class="ide-stage-collapse-icon" name="angle-down" size="16" diff --git a/spec/frontend/ide/components/jobs/detail/description_spec.js b/spec/frontend/ide/components/jobs/detail/description_spec.js index babae00d2f7..5554738336a 100644 --- a/spec/frontend/ide/components/jobs/detail/description_spec.js +++ b/spec/frontend/ide/components/jobs/detail/description_spec.js @@ -23,6 +23,16 @@ describe('IDE job description', () => { }); it('renders CI icon', () => { - expect(vm.$el.querySelector('.ci-status-icon .ic-status_success_borderless')).not.toBe(null); + expect( + vm.$el.querySelector('.ci-status-icon [data-testid="status_success_borderless-icon"]'), + ).not.toBe(null); + }); + + it('renders bridge job details without the job link', () => { + vm = mountComponent(Component, { + job: { ...jobs[0], path: undefined }, + }); + + expect(vm.$el.querySelector('[data-testid="description-detail-link"]')).toBe(null); }); }); diff --git a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js index b8dbca97ade..42526590ebb 100644 --- a/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js +++ b/spec/frontend/ide/components/jobs/detail/scroll_button_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Icon from '~/vue_shared/components/icon.vue'; +import { GlIcon } from '@gitlab/ui'; import ScrollButton from '~/ide/components/jobs/detail/scroll_button.vue'; describe('IDE job log scroll button', () => { @@ -27,7 +27,7 @@ describe('IDE job log scroll button', () => { beforeEach(() => createComponent({ direction })); it('returns proper icon name', () => { - expect(wrapper.find(Icon).props('name')).toBe(icon); + expect(wrapper.find(GlIcon).props('name')).toBe(icon); }); it('returns proper title', () => { diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js index acd30dee718..496d8284fdd 100644 --- a/spec/frontend/ide/components/jobs/detail_spec.js +++ b/spec/frontend/ide/components/jobs/detail_spec.js @@ -24,7 +24,7 @@ describe('IDE jobs detail view', () => { beforeEach(() => { vm = createComponent(); - jest.spyOn(vm, 'fetchJobTrace').mockResolvedValue(); + jest.spyOn(vm, 'fetchJobLogs').mockResolvedValue(); }); afterEach(() => { @@ -36,8 +36,8 @@ describe('IDE jobs detail view', () => { vm = vm.$mount(); }); - it('calls fetchJobTrace', () => { - expect(vm.fetchJobTrace).toHaveBeenCalled(); + it('calls fetchJobLogs', () => { + expect(vm.fetchJobLogs).toHaveBeenCalled(); }); it('scrolls to bottom', () => { @@ -96,7 +96,7 @@ describe('IDE jobs detail view', () => { describe('scroll buttons', () => { beforeEach(() => { vm = createComponent(); - jest.spyOn(vm, 'fetchJobTrace').mockResolvedValue(); + jest.spyOn(vm, 'fetchJobLogs').mockResolvedValue(); }); afterEach(() => { diff --git a/spec/frontend/ide/components/jobs/item_spec.js b/spec/frontend/ide/components/jobs/item_spec.js index 2f97d39e98e..93c01640b54 100644 --- a/spec/frontend/ide/components/jobs/item_spec.js +++ b/spec/frontend/ide/components/jobs/item_spec.js @@ -24,7 +24,7 @@ describe('IDE jobs item', () => { }); it('renders CI icon', () => { - expect(vm.$el.querySelector('.ic-status_success_borderless')).not.toBe(null); + expect(vm.$el.querySelector('[data-testid="status_success_borderless-icon"]')).not.toBe(null); }); it('does not render view logs button if not started', done => { diff --git a/spec/frontend/ide/components/jobs/list_spec.js b/spec/frontend/ide/components/jobs/list_spec.js index d8880fa7cb7..e821a585e18 100644 --- a/spec/frontend/ide/components/jobs/list_spec.js +++ b/spec/frontend/ide/components/jobs/list_spec.js @@ -99,11 +99,7 @@ describe('IDE stages list', () => { it('calls toggleStageCollapsed when clicking stage header', () => { findCardHeader().trigger('click'); - expect(storeActions.toggleStageCollapsed).toHaveBeenCalledWith( - expect.any(Object), - 0, - undefined, - ); + expect(storeActions.toggleStageCollapsed).toHaveBeenCalledWith(expect.any(Object), 0); }); it('calls fetchJobs when stage is mounted', () => { diff --git a/spec/frontend/ide/components/merge_requests/item_spec.js b/spec/frontend/ide/components/merge_requests/item_spec.js index b1da89d7a9b..20adaa7abbc 100644 --- a/spec/frontend/ide/components/merge_requests/item_spec.js +++ b/spec/frontend/ide/components/merge_requests/item_spec.js @@ -33,7 +33,7 @@ describe('IDE merge request item', () => { store, }); }; - const findIcon = () => wrapper.find('.ic-mobile-issue-close'); + const findIcon = () => wrapper.find('[data-testid="mobile-issue-close-icon"]'); beforeEach(() => { store = createStore(); diff --git a/spec/frontend/ide/components/merge_requests/list_spec.js b/spec/frontend/ide/components/merge_requests/list_spec.js index e2c6ac49e07..80dcd861451 100644 --- a/spec/frontend/ide/components/merge_requests/list_spec.js +++ b/spec/frontend/ide/components/merge_requests/list_spec.js @@ -56,14 +56,10 @@ describe('IDE merge requests list', () => { it('calls fetch on mounted', () => { createComponent(); - expect(fetchMergeRequestsMock).toHaveBeenCalledWith( - expect.any(Object), - { - search: '', - type: '', - }, - undefined, - ); + expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), { + search: '', + type: '', + }); }); it('renders loading icon when merge request is loading', () => { @@ -95,14 +91,10 @@ describe('IDE merge requests list', () => { const searchType = wrapper.vm.$options.searchTypes[0]; expect(findTokenedInput().props('tokens')).toEqual([searchType]); - expect(fetchMergeRequestsMock).toHaveBeenCalledWith( - expect.any(Object), - { - type: searchType.type, - search: '', - }, - undefined, - ); + expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), { + type: searchType.type, + search: '', + }); }); }); @@ -136,14 +128,10 @@ describe('IDE merge requests list', () => { input.vm.$emit('input', 'something'); return wrapper.vm.$nextTick().then(() => { - expect(fetchMergeRequestsMock).toHaveBeenCalledWith( - expect.any(Object), - { - search: 'something', - type: '', - }, - undefined, - ); + expect(fetchMergeRequestsMock).toHaveBeenCalledWith(expect.any(Object), { + search: 'something', + type: '', + }); }); }); }); diff --git a/spec/frontend/ide/components/nav_dropdown_button_spec.js b/spec/frontend/ide/components/nav_dropdown_button_spec.js index 2aa3992a6d8..c98aa313f40 100644 --- a/spec/frontend/ide/components/nav_dropdown_button_spec.js +++ b/spec/frontend/ide/components/nav_dropdown_button_spec.js @@ -23,7 +23,7 @@ describe('NavDropdown', () => { vm.$mount(); }; - const findIcon = name => vm.$el.querySelector(`.ic-${name}`); + const findIcon = name => vm.$el.querySelector(`[data-testid="${name}-icon"]`); const findMRIcon = () => findIcon('merge-request'); const findBranchIcon = () => findIcon('branch'); diff --git a/spec/frontend/ide/components/nav_dropdown_spec.js b/spec/frontend/ide/components/nav_dropdown_spec.js index ce123d925c8..2f91ab7af0a 100644 --- a/spec/frontend/ide/components/nav_dropdown_spec.js +++ b/spec/frontend/ide/components/nav_dropdown_spec.js @@ -39,7 +39,7 @@ describe('IDE NavDropdown', () => { }); }; - const findIcon = name => wrapper.find(`.ic-${name}`); + const findIcon = name => wrapper.find(`[data-testid="${name}-icon"]`); const findMRIcon = () => findIcon('merge-request'); const findNavForm = () => wrapper.find('.ide-nav-form'); const showDropdown = () => { diff --git a/spec/frontend/ide/components/new_dropdown/button_spec.js b/spec/frontend/ide/components/new_dropdown/button_spec.js index 3c611b7de8f..66317296ee9 100644 --- a/spec/frontend/ide/components/new_dropdown/button_spec.js +++ b/spec/frontend/ide/components/new_dropdown/button_spec.js @@ -28,7 +28,7 @@ describe('IDE new entry dropdown button component', () => { }); it('renders icon', () => { - expect(vm.$el.querySelector('.ic-doc-new')).not.toBe(null); + expect(vm.$el.querySelector('[data-testid="doc-new-icon"]')).not.toBe(null); }); it('emits click event', () => { diff --git a/spec/frontend/ide/components/new_dropdown/upload_spec.js b/spec/frontend/ide/components/new_dropdown/upload_spec.js index ad27954cd10..ae497106f73 100644 --- a/spec/frontend/ide/components/new_dropdown/upload_spec.js +++ b/spec/frontend/ide/components/new_dropdown/upload_spec.js @@ -85,7 +85,6 @@ describe('new dropdown upload', () => { name: textFile.name, type: 'blob', content: 'plain text', - binary: false, rawPath: '', }); }) @@ -102,7 +101,6 @@ describe('new dropdown upload', () => { name: binaryFile.name, type: 'blob', content: binaryTarget.result.split('base64,')[1], - binary: true, rawPath: binaryTarget.result, }); }); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 86cdbafaff9..7f083fa7c25 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -22,11 +22,11 @@ describe('IDE pipelines list', () => { const defaultState = { links: { ciHelpPagePath: TEST_HOST }, pipelinesEmptyStateSvgPath: TEST_HOST, - pipelines: { - stages: [], - failedStages: [], - isLoadingJobs: false, - }, + }; + const defaultPipelinesState = { + stages: [], + failedStages: [], + isLoadingJobs: false, }; const fetchLatestPipelineMock = jest.fn(); @@ -34,23 +34,20 @@ describe('IDE pipelines list', () => { const failedStagesGetterMock = jest.fn().mockReturnValue([]); const fakeProjectPath = 'alpha/beta'; - const createComponent = (state = {}) => { - const { pipelines: pipelinesState, ...restOfState } = state; - const { defaultPipelines, ...defaultRestOfState } = defaultState; - - const fakeStore = new Vuex.Store({ + const createStore = (rootState, pipelinesState) => { + return new Vuex.Store({ getters: { currentProject: () => ({ web_url: 'some/url ', path_with_namespace: fakeProjectPath }), }, state: { - ...defaultRestOfState, - ...restOfState, + ...defaultState, + ...rootState, }, modules: { pipelines: { namespaced: true, state: { - ...defaultPipelines, + ...defaultPipelinesState, ...pipelinesState, }, actions: { @@ -69,10 +66,12 @@ describe('IDE pipelines list', () => { }, }, }); + }; + const createComponent = (state = {}, pipelinesState = {}) => { wrapper = shallowMount(List, { localVue, - store: fakeStore, + store: createStore(state, pipelinesState), }); }; @@ -94,31 +93,33 @@ describe('IDE pipelines list', () => { describe('when loading', () => { let defaultPipelinesLoadingState; + beforeAll(() => { defaultPipelinesLoadingState = { - ...defaultState.pipelines, isLoadingPipeline: true, }; }); it('does not render when pipeline has loaded before', () => { - createComponent({ - pipelines: { + createComponent( + {}, + { ...defaultPipelinesLoadingState, hasLoadedPipeline: true, }, - }); + ); expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); }); it('renders loading state', () => { - createComponent({ - pipelines: { + createComponent( + {}, + { ...defaultPipelinesLoadingState, hasLoadedPipeline: false, }, - }); + ); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); @@ -126,21 +127,22 @@ describe('IDE pipelines list', () => { describe('when loaded', () => { let defaultPipelinesLoadedState; + beforeAll(() => { defaultPipelinesLoadedState = { - ...defaultState.pipelines, isLoadingPipeline: false, hasLoadedPipeline: true, }; }); it('renders empty state when no latestPipeline', () => { - createComponent({ pipelines: { ...defaultPipelinesLoadedState, latestPipeline: null } }); + createComponent({}, { ...defaultPipelinesLoadedState, latestPipeline: null }); expect(wrapper.element).toMatchSnapshot(); }); describe('with latest pipeline loaded', () => { let withLatestPipelineState; + beforeAll(() => { withLatestPipelineState = { ...defaultPipelinesLoadedState, @@ -149,12 +151,12 @@ describe('IDE pipelines list', () => { }); it('renders ci icon', () => { - createComponent({ pipelines: withLatestPipelineState }); + createComponent({}, withLatestPipelineState); expect(wrapper.find(CiIcon).exists()).toBe(true); }); it('renders pipeline data', () => { - createComponent({ pipelines: withLatestPipelineState }); + createComponent({}, withLatestPipelineState); expect(wrapper.text()).toContain('#1'); }); @@ -162,7 +164,7 @@ describe('IDE pipelines list', () => { it('renders list of jobs', () => { const stages = []; const isLoadingJobs = true; - createComponent({ pipelines: { ...withLatestPipelineState, stages, isLoadingJobs } }); + createComponent({}, { ...withLatestPipelineState, stages, isLoadingJobs }); const jobProps = wrapper .findAll(Tab) @@ -177,7 +179,7 @@ describe('IDE pipelines list', () => { const failedStages = []; failedStagesGetterMock.mockReset().mockReturnValue(failedStages); const isLoadingJobs = true; - createComponent({ pipelines: { ...withLatestPipelineState, isLoadingJobs } }); + createComponent({}, { ...withLatestPipelineState, isLoadingJobs }); const jobProps = wrapper .findAll(Tab) @@ -191,12 +193,13 @@ describe('IDE pipelines list', () => { describe('with YAML error', () => { it('renders YAML error', () => { const yamlError = 'test yaml error'; - createComponent({ - pipelines: { + createComponent( + {}, + { ...defaultPipelinesLoadedState, latestPipeline: { ...pipelines[0], yamlError }, }, - }); + ); expect(wrapper.text()).toContain('Found errors in your .gitlab-ci.yml:'); expect(wrapper.text()).toContain(yamlError); diff --git a/spec/frontend/ide/components/preview/clientside_spec.js b/spec/frontend/ide/components/preview/clientside_spec.js index 7b2025f5e9f..7b22f75cee4 100644 --- a/spec/frontend/ide/components/preview/clientside_spec.js +++ b/spec/frontend/ide/components/preview/clientside_spec.js @@ -279,24 +279,16 @@ describe('IDE clientside preview', () => { }); it('calls getFileData', () => { - expect(storeActions.getFileData).toHaveBeenCalledWith( - expect.any(Object), - { - path: 'package.json', - makeFileActive: false, - }, - undefined, // vuex callback - ); + expect(storeActions.getFileData).toHaveBeenCalledWith(expect.any(Object), { + path: 'package.json', + makeFileActive: false, + }); }); it('calls getRawFileData', () => { - expect(storeActions.getRawFileData).toHaveBeenCalledWith( - expect.any(Object), - { - path: 'package.json', - }, - undefined, // vuex callback - ); + expect(storeActions.getRawFileData).toHaveBeenCalledWith(expect.any(Object), { + path: 'package.json', + }); }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index f0ae2ba732b..9f4c9c1622a 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -45,7 +45,7 @@ describe('RepoEditor', () => { const createOpenFile = path => { const origFile = store.state.openFiles[0]; - const newFile = { ...origFile, path, key: path }; + const newFile = { ...origFile, path, key: path, name: 'myfile.txt', content: 'hello world' }; store.state.entries[path] = newFile; @@ -54,8 +54,9 @@ describe('RepoEditor', () => { beforeEach(() => { const f = { - ...file(), + ...file('file.txt'), viewMode: FILE_VIEW_MODE_EDITOR, + content: 'hello world', }; const storeOptions = createStoreOptions(); @@ -142,6 +143,7 @@ describe('RepoEditor', () => { ...vm.file, projectId: 'namespace/project', path: 'sample.md', + name: 'sample.md', content: 'testing 123', }); @@ -200,7 +202,8 @@ describe('RepoEditor', () => { describe('when open file is binary and not raw', () => { beforeEach(done => { - vm.file.binary = true; + vm.file.name = 'file.dat'; + vm.file.content = '🐱'; // non-ascii binary content vm.$nextTick(done); }); @@ -407,6 +410,9 @@ describe('RepoEditor', () => { beforeEach(done => { jest.spyOn(vm.editor, 'updateDimensions').mockImplementation(); vm.file.viewMode = FILE_VIEW_MODE_PREVIEW; + vm.file.name = 'myfile.md'; + vm.file.content = 'hello world'; + vm.$nextTick(done); }); @@ -650,7 +656,6 @@ describe('RepoEditor', () => { path: 'foo/foo.png', type: 'blob', content: 'Zm9v', - binary: true, rawPath: 'data:image/png;base64,Zm9v', }); }); diff --git a/spec/frontend/ide/components/repo_tab_spec.js b/spec/frontend/ide/components/repo_tab_spec.js index 5a591d3dcd0..f35726de27c 100644 --- a/spec/frontend/ide/components/repo_tab_spec.js +++ b/spec/frontend/ide/components/repo_tab_spec.js @@ -1,21 +1,24 @@ -import Vue from 'vue'; +import { mount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; import { createStore } from '~/ide/stores'; -import repoTab from '~/ide/components/repo_tab.vue'; +import RepoTab from '~/ide/components/repo_tab.vue'; import { createRouter } from '~/ide/ide_router'; import { file } from '../helpers'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('RepoTab', () => { - let vm; + let wrapper; let store; let router; function createComponent(propsData) { - const RepoTab = Vue.extend(repoTab); - - return new RepoTab({ + wrapper = mount(RepoTab, { + localVue, store, propsData, - }).$mount(); + }); } beforeEach(() => { @@ -25,23 +28,24 @@ describe('RepoTab', () => { }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; }); it('renders a close link and a name link', () => { - vm = createComponent({ + createComponent({ tab: file(), }); - vm.$store.state.openFiles.push(vm.tab); - const close = vm.$el.querySelector('.multi-file-tab-close'); - const name = vm.$el.querySelector(`[title="${vm.tab.url}"]`); + wrapper.vm.$store.state.openFiles.push(wrapper.vm.tab); + const close = wrapper.find('.multi-file-tab-close'); + const name = wrapper.find(`[title]`); - expect(close.innerHTML).toContain('#close'); - expect(name.textContent.trim()).toEqual(vm.tab.name); + expect(close.html()).toContain('#close'); + expect(name.text().trim()).toEqual(wrapper.vm.tab.name); }); - it('does not call openPendingTab when tab is active', done => { - vm = createComponent({ + it('does not call openPendingTab when tab is active', async () => { + createComponent({ tab: { ...file(), pending: true, @@ -49,63 +53,51 @@ describe('RepoTab', () => { }, }); - jest.spyOn(vm, 'openPendingTab').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'openPendingTab').mockImplementation(() => {}); - vm.$el.click(); + await wrapper.trigger('click'); - vm.$nextTick(() => { - expect(vm.openPendingTab).not.toHaveBeenCalled(); - - done(); - }); + expect(wrapper.vm.openPendingTab).not.toHaveBeenCalled(); }); it('fires clickFile when the link is clicked', () => { - vm = createComponent({ + createComponent({ tab: file(), }); - jest.spyOn(vm, 'clickFile').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'clickFile').mockImplementation(() => {}); - vm.$el.click(); + wrapper.trigger('click'); - expect(vm.clickFile).toHaveBeenCalledWith(vm.tab); + expect(wrapper.vm.clickFile).toHaveBeenCalledWith(wrapper.vm.tab); }); it('calls closeFile when clicking close button', () => { - vm = createComponent({ + createComponent({ tab: file(), }); - jest.spyOn(vm, 'closeFile').mockImplementation(() => {}); + jest.spyOn(wrapper.vm, 'closeFile').mockImplementation(() => {}); - vm.$el.querySelector('.multi-file-tab-close').click(); + wrapper.find('.multi-file-tab-close').trigger('click'); - expect(vm.closeFile).toHaveBeenCalledWith(vm.tab); + expect(wrapper.vm.closeFile).toHaveBeenCalledWith(wrapper.vm.tab); }); - it('changes icon on hover', done => { + it('changes icon on hover', async () => { const tab = file(); tab.changed = true; - vm = createComponent({ + createComponent({ tab, }); - vm.$el.dispatchEvent(new Event('mouseover')); + await wrapper.trigger('mouseover'); - Vue.nextTick() - .then(() => { - expect(vm.$el.querySelector('.file-modified')).toBeNull(); + expect(wrapper.find('.file-modified').exists()).toBe(false); - vm.$el.dispatchEvent(new Event('mouseout')); - }) - .then(Vue.nextTick) - .then(() => { - expect(vm.$el.querySelector('.file-modified')).not.toBeNull(); + await wrapper.trigger('mouseout'); - done(); - }) - .catch(done.fail); + expect(wrapper.find('.file-modified').exists()).toBe(true); }); describe('locked file', () => { @@ -120,21 +112,17 @@ describe('RepoTab', () => { }, }; - vm = createComponent({ + createComponent({ tab: f, }); }); - afterEach(() => { - vm.$destroy(); - }); - it('renders lock icon', () => { - expect(vm.$el.querySelector('.file-status-icon')).not.toBeNull(); + expect(wrapper.find('.file-status-icon')).not.toBeNull(); }); it('renders a tooltip', () => { - expect(vm.$el.querySelector('span:nth-child(2)').dataset.originalTitle).toContain( + expect(wrapper.find('span:nth-child(2)').attributes('data-original-title')).toContain( 'Locked by testuser', ); }); @@ -142,45 +130,37 @@ describe('RepoTab', () => { describe('methods', () => { describe('closeTab', () => { - it('closes tab if file has changed', done => { + it('closes tab if file has changed', async () => { const tab = file(); tab.changed = true; tab.opened = true; - vm = createComponent({ + createComponent({ tab, }); - vm.$store.state.openFiles.push(tab); - vm.$store.state.changedFiles.push(tab); - vm.$store.state.entries[tab.path] = tab; - vm.$store.dispatch('setFileActive', tab.path); - - vm.$el.querySelector('.multi-file-tab-close').click(); + wrapper.vm.$store.state.openFiles.push(tab); + wrapper.vm.$store.state.changedFiles.push(tab); + wrapper.vm.$store.state.entries[tab.path] = tab; + wrapper.vm.$store.dispatch('setFileActive', tab.path); - vm.$nextTick(() => { - expect(tab.opened).toBeFalsy(); - expect(vm.$store.state.changedFiles.length).toBe(1); + await wrapper.find('.multi-file-tab-close').trigger('click'); - done(); - }); + expect(tab.opened).toBeFalsy(); + expect(wrapper.vm.$store.state.changedFiles).toHaveLength(1); }); - it('closes tab when clicking close btn', done => { + it('closes tab when clicking close btn', async () => { const tab = file('lose'); tab.opened = true; - vm = createComponent({ + createComponent({ tab, }); - vm.$store.state.openFiles.push(tab); - vm.$store.state.entries[tab.path] = tab; - vm.$store.dispatch('setFileActive', tab.path); + wrapper.vm.$store.state.openFiles.push(tab); + wrapper.vm.$store.state.entries[tab.path] = tab; + wrapper.vm.$store.dispatch('setFileActive', tab.path); - vm.$el.querySelector('.multi-file-tab-close').click(); + await wrapper.find('.multi-file-tab-close').trigger('click'); - vm.$nextTick(() => { - expect(tab.opened).toBeFalsy(); - - done(); - }); + expect(tab.opened).toBeFalsy(); }); }); }); diff --git a/spec/frontend/ide/components/repo_tabs_spec.js b/spec/frontend/ide/components/repo_tabs_spec.js index df5b01770f5..b251f207853 100644 --- a/spec/frontend/ide/components/repo_tabs_spec.js +++ b/spec/frontend/ide/components/repo_tabs_spec.js @@ -1,27 +1,40 @@ -import Vue from 'vue'; -import repoTabs from '~/ide/components/repo_tabs.vue'; -import createComponent from '../../helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { createStore } from '~/ide/stores'; +import RepoTabs from '~/ide/components/repo_tabs.vue'; import { file } from '../helpers'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('RepoTabs', () => { - const openedFiles = [file('open1'), file('open2')]; - const RepoTabs = Vue.extend(repoTabs); - let vm; + let wrapper; + let store; + + beforeEach(() => { + store = createStore(); + store.state.openFiles = [file('open1'), file('open2')]; + + wrapper = mount(RepoTabs, { + propsData: { + files: store.state.openFiles, + viewer: 'editor', + activeFile: file('activeFile'), + }, + store, + localVue, + }); + }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders a list of tabs', done => { - vm = createComponent(RepoTabs, { - files: openedFiles, - viewer: 'editor', - activeFile: file('activeFile'), - }); - openedFiles[0].active = true; + store.state.openFiles[0].active = true; - vm.$nextTick(() => { - const tabs = [...vm.$el.querySelectorAll('.multi-file-tab')]; + wrapper.vm.$nextTick(() => { + const tabs = [...wrapper.vm.$el.querySelectorAll('.multi-file-tab')]; expect(tabs.length).toEqual(2); expect(tabs[0].parentNode.classList.contains('active')).toEqual(true); diff --git a/spec/frontend/ide/components/terminal/session_spec.js b/spec/frontend/ide/components/terminal/session_spec.js index 2399446ed15..ce61a31691a 100644 --- a/spec/frontend/ide/components/terminal/session_spec.js +++ b/spec/frontend/ide/components/terminal/session_spec.js @@ -52,7 +52,7 @@ describe('IDE TerminalSession', () => { state.session = null; factory(); - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); it('shows terminal', () => { diff --git a/spec/frontend/ide/components/terminal/terminal_controls_spec.js b/spec/frontend/ide/components/terminal/terminal_controls_spec.js index 6c2871abb46..c22063e1d72 100644 --- a/spec/frontend/ide/components/terminal/terminal_controls_spec.js +++ b/spec/frontend/ide/components/terminal/terminal_controls_spec.js @@ -42,24 +42,24 @@ describe('IDE TerminalControls', () => { it('emits "scroll-up" when click up button', () => { factory({ propsData: { canScrollUp: true } }); - expect(wrapper.emittedByOrder()).toEqual([]); + expect(wrapper.emitted()).toEqual({}); buttons.at(0).vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-up', args: [] }]); + expect(wrapper.emitted('scroll-up')).toEqual([[]]); }); }); it('emits "scroll-down" when click down button', () => { factory({ propsData: { canScrollDown: true } }); - expect(wrapper.emittedByOrder()).toEqual([]); + expect(wrapper.emitted()).toEqual({}); buttons.at(1).vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emittedByOrder()).toEqual([{ name: 'scroll-down', args: [] }]); + expect(wrapper.emitted('scroll-down')).toEqual([[]]); }); }); }); diff --git a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js index 16a76fae1dd..9adf5848f9d 100644 --- a/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js +++ b/spec/frontend/ide/components/terminal_sync/terminal_sync_status_spec.js @@ -1,13 +1,12 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import TerminalSyncStatus from '~/ide/components/terminal_sync/terminal_sync_status.vue'; import { MSG_TERMINAL_SYNC_CONNECTING, MSG_TERMINAL_SYNC_UPLOADING, MSG_TERMINAL_SYNC_RUNNING, } from '~/ide/stores/modules/terminal_sync/messages'; -import Icon from '~/vue_shared/components/icon.vue'; const TEST_MESSAGE = 'lorem ipsum dolar sit'; const START_LOADING = 'START_LOADING'; @@ -58,7 +57,7 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => { it('shows nothing', () => { createComponent(); - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); }); @@ -80,7 +79,7 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => { if (!icon) { it('does not render icon', () => { - expect(wrapper.find(Icon).exists()).toBe(false); + expect(wrapper.find(GlIcon).exists()).toBe(false); }); it('renders loading icon', () => { @@ -88,7 +87,7 @@ describe('ide/components/terminal_sync/terminal_sync_status', () => { }); } else { it('renders icon', () => { - expect(wrapper.find(Icon).props('name')).toEqual(icon); + expect(wrapper.find(GlIcon).props('name')).toEqual(icon); }); it('does not render loading icon', () => { diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js index 8caa9c2b437..0e85b523cbd 100644 --- a/spec/frontend/ide/helpers.js +++ b/spec/frontend/ide/helpers.js @@ -6,7 +6,6 @@ export const file = (name = 'name', id = name, type = '', parent = null) => id, type, icon: 'icon', - url: 'url', name, path: parent ? `${parent.path}/${name}` : name, parentPath: parent ? parent.path : '', diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js index 529f80e6f6f..01c2eab33a5 100644 --- a/spec/frontend/ide/lib/editor_spec.js +++ b/spec/frontend/ide/lib/editor_spec.js @@ -202,28 +202,6 @@ describe('Multi-file editor library', () => { }); }); - describe('schemas', () => { - let originalGon; - - beforeEach(() => { - originalGon = window.gon; - window.gon = { features: { schemaLinting: true } }; - - delete Editor.editorInstance; - instance = Editor.create(); - }); - - afterEach(() => { - window.gon = originalGon; - }); - - it('registers custom schemas defined with Monaco', () => { - expect(monacoLanguages.yaml.yamlDefaults.diagnosticsOptions).toMatchObject({ - schemas: [{ fileMatch: ['*.gitlab-ci.yml'] }], - }); - }); - }); - describe('replaceSelectedText', () => { let model; let editor; diff --git a/spec/frontend/ide/lib/errors_spec.js b/spec/frontend/ide/lib/errors_spec.js new file mode 100644 index 00000000000..8c3fb378302 --- /dev/null +++ b/spec/frontend/ide/lib/errors_spec.js @@ -0,0 +1,70 @@ +import { + createUnexpectedCommitError, + createCodeownersCommitError, + createBranchChangedCommitError, + parseCommitError, +} from '~/ide/lib/errors'; + +const TEST_SPECIAL = '&special<'; +const TEST_SPECIAL_ESCAPED = '&special<'; +const TEST_MESSAGE = 'Test message.'; +const CODEOWNERS_MESSAGE = + 'Push to protected branches that contain changes to files matching CODEOWNERS is not allowed'; +const CHANGED_MESSAGE = 'Things changed since you started editing'; + +describe('~/ide/lib/errors', () => { + const createResponseError = message => ({ + response: { + data: { + message, + }, + }, + }); + + describe('createCodeownersCommitError', () => { + it('uses given message', () => { + expect(createCodeownersCommitError(TEST_MESSAGE)).toEqual({ + title: 'CODEOWNERS rule violation', + messageHTML: TEST_MESSAGE, + canCreateBranch: true, + }); + }); + + it('escapes special chars', () => { + expect(createCodeownersCommitError(TEST_SPECIAL)).toEqual({ + title: 'CODEOWNERS rule violation', + messageHTML: TEST_SPECIAL_ESCAPED, + canCreateBranch: true, + }); + }); + }); + + describe('createBranchChangedCommitError', () => { + it.each` + message | expectedMessage + ${TEST_MESSAGE} | ${`${TEST_MESSAGE}<br/><br/>Would you like to create a new branch?`} + ${TEST_SPECIAL} | ${`${TEST_SPECIAL_ESCAPED}<br/><br/>Would you like to create a new branch?`} + `('uses given message="$message"', ({ message, expectedMessage }) => { + expect(createBranchChangedCommitError(message)).toEqual({ + title: 'Branch changed', + messageHTML: expectedMessage, + canCreateBranch: true, + }); + }); + }); + + describe('parseCommitError', () => { + it.each` + message | expectation + ${null} | ${createUnexpectedCommitError()} + ${{}} | ${createUnexpectedCommitError()} + ${{ response: {} }} | ${createUnexpectedCommitError()} + ${{ response: { data: {} } }} | ${createUnexpectedCommitError()} + ${createResponseError('test')} | ${createUnexpectedCommitError()} + ${createResponseError(CODEOWNERS_MESSAGE)} | ${createCodeownersCommitError(CODEOWNERS_MESSAGE)} + ${createResponseError(CHANGED_MESSAGE)} | ${createBranchChangedCommitError(CHANGED_MESSAGE)} + `('parses message into error object with "$message"', ({ message, expectation }) => { + expect(parseCommitError(message)).toEqual(expectation); + }); + }); +}); diff --git a/spec/frontend/ide/lib/files_spec.js b/spec/frontend/ide/lib/files_spec.js index 6974cdc4074..8ca6f01d9a6 100644 --- a/spec/frontend/ide/lib/files_spec.js +++ b/spec/frontend/ide/lib/files_spec.js @@ -1,29 +1,16 @@ -import { viewerInformationForPath } from '~/vue_shared/components/content_viewer/lib/viewer_utils'; import { decorateFiles, splitParent } from '~/ide/lib/files'; import { decorateData } from '~/ide/stores/utils'; -const TEST_BRANCH_ID = 'lorem-ipsum'; -const TEST_PROJECT_ID = 10; - const createEntries = paths => { const createEntry = (acc, { path, type, children }) => { - // Sometimes we need to end the url with a '/' - const createUrl = base => (type === 'tree' ? `${base}/` : base); - const { name, parent } = splitParent(path); - const previewMode = viewerInformationForPath(name); acc[path] = { ...decorateData({ - projectId: TEST_PROJECT_ID, - branchId: TEST_BRANCH_ID, id: path, name, path, - url: createUrl(`/${TEST_PROJECT_ID}/${type}/${TEST_BRANCH_ID}/-/${path}`), type, - previewMode, - binary: (previewMode && previewMode.binary) || false, parentPath: parent, }), tree: children.map(childName => expect.objectContaining({ name: childName })), @@ -56,11 +43,7 @@ describe('IDE lib decorate files', () => { { path: 'README.md', type: 'blob', children: [] }, ]); - const { entries, treeList } = decorateFiles({ - data, - branchId: TEST_BRANCH_ID, - projectId: TEST_PROJECT_ID, - }); + const { entries, treeList } = decorateFiles({ data }); // Here we test the keys and then each key/value individually because `expect(entries).toEqual(expectedEntries)` // was taking a very long time for some reason. Probably due to large objects and nested `expect.objectContaining`. diff --git a/spec/frontend/ide/mock_data.js b/spec/frontend/ide/mock_data.js index 472516b6a2c..c8925e6745d 100644 --- a/spec/frontend/ide/mock_data.js +++ b/spec/frontend/ide/mock_data.js @@ -112,7 +112,8 @@ export const jobs = [ { id: 4, name: 'test 4', - path: 'testing4', + // bridge jobs don't have details page and so there is no path attribute + // see https://gitlab.com/gitlab-org/gitlab/-/issues/216480 status: { icon: 'status_failed', text: 'failed', diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index bc3f86702cf..d2c32a81811 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -146,7 +146,7 @@ describe('IDE services', () => { it('gives back file.baseRaw for files with that property present', () => { file.baseRaw = TEST_FILE_CONTENTS; - return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + return services.getBaseRawFileData(file, TEST_PROJECT_ID, TEST_COMMIT_SHA).then(content => { expect(content).toEqual(TEST_FILE_CONTENTS); }); }); @@ -155,7 +155,7 @@ describe('IDE services', () => { file.tempFile = true; file.baseRaw = TEST_FILE_CONTENTS; - return services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + return services.getBaseRawFileData(file, TEST_PROJECT_ID, TEST_COMMIT_SHA).then(content => { expect(content).toEqual(TEST_FILE_CONTENTS); }); }); @@ -192,7 +192,7 @@ describe('IDE services', () => { }); it('fetches file content', () => - services.getBaseRawFileData(file, TEST_COMMIT_SHA).then(content => { + services.getBaseRawFileData(file, TEST_PROJECT_ID, TEST_COMMIT_SHA).then(content => { expect(content).toEqual(TEST_FILE_CONTENTS); })); }, diff --git a/spec/frontend/ide/stores/actions/file_spec.js b/spec/frontend/ide/stores/actions/file_spec.js index 88e7a9fff36..974c0715c06 100644 --- a/spec/frontend/ide/stores/actions/file_spec.js +++ b/spec/frontend/ide/stores/actions/file_spec.js @@ -27,6 +27,10 @@ describe('IDE store file actions', () => { }; store = createStore(); + + store.state.currentProjectId = 'test/test'; + store.state.currentBranchId = 'master'; + router = createRouter(store); jest.spyOn(store, 'commit'); @@ -72,10 +76,7 @@ describe('IDE store file actions', () => { }); it('closes file & opens next available file', () => { - const f = { - ...file('newOpenFile'), - url: '/newOpenFile', - }; + const f = file('newOpenFile'); store.state.openFiles.push(f); store.state.entries[f.path] = f; @@ -84,7 +85,7 @@ describe('IDE store file actions', () => { .dispatch('closeFile', localFile) .then(Vue.nextTick) .then(() => { - expect(router.push).toHaveBeenCalledWith(`/project${f.url}`); + expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/-/newOpenFile/'); }); }); @@ -240,7 +241,6 @@ describe('IDE store file actions', () => { 200, { raw_path: 'raw_path', - binary: false, }, { 'page-title': 'testing getFileData', @@ -296,7 +296,6 @@ describe('IDE store file actions', () => { describe('Re-named success', () => { beforeEach(() => { localFile = file(`newCreate-${Math.random()}`); - localFile.url = `project/getFileDataURL`; localFile.prevPath = 'old-dull-file'; localFile.path = 'new-shiny-file'; store.state.entries[localFile.path] = localFile; @@ -305,7 +304,6 @@ describe('IDE store file actions', () => { 200, { raw_path: 'raw_path', - binary: false, }, { 'page-title': 'testing old-dull-file', @@ -393,7 +391,11 @@ describe('IDE store file actions', () => { tmpFile.mrChange = { new_file: false }; return store.dispatch('getRawFileData', { path: tmpFile.path }).then(() => { - expect(service.getBaseRawFileData).toHaveBeenCalledWith(tmpFile, 'SHA'); + expect(service.getBaseRawFileData).toHaveBeenCalledWith( + tmpFile, + 'gitlab-org/gitlab-ce', + 'SHA', + ); expect(tmpFile.baseRaw).toBe('baseraw'); }); }); @@ -660,7 +662,7 @@ describe('IDE store file actions', () => { }); it('pushes route for active file', () => { - expect(router.push).toHaveBeenCalledWith(`/project${tmpFile.url}`); + expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/-/tempFile/'); }); }); }); @@ -735,10 +737,8 @@ describe('IDE store file actions', () => { }); it('pushes router URL when added', () => { - store.state.currentBranchId = 'master'; - return store.dispatch('openPendingTab', { file: f, keyPrefix: 'pending' }).then(() => { - expect(router.push).toHaveBeenCalledWith('/project/123/tree/master/'); + expect(router.push).toHaveBeenCalledWith('/project/test/test/tree/master/'); }); }); }); diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index 62971b9cad6..b1cceda9d85 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -453,11 +453,9 @@ describe('IDE store merge request actions', () => { it('updates activity bar view and gets file data, if changes are found', done => { store.state.entries.foo = { - url: 'test', type: 'blob', }; store.state.entries.bar = { - url: 'test', type: 'blob', }; diff --git a/spec/frontend/ide/stores/actions_spec.js b/spec/frontend/ide/stores/actions_spec.js index f77dbd80025..ebf39df2f6f 100644 --- a/spec/frontend/ide/stores/actions_spec.js +++ b/spec/frontend/ide/stores/actions_spec.js @@ -123,7 +123,6 @@ describe('Multi-file store actions', () => { it('creates temp tree', done => { store .dispatch('createTempEntry', { - branchId: store.state.currentBranchId, name: 'test', type: 'tree', }) @@ -150,7 +149,6 @@ describe('Multi-file store actions', () => { store .dispatch('createTempEntry', { - branchId: store.state.currentBranchId, name: 'testing/test', type: 'tree', }) @@ -176,7 +174,6 @@ describe('Multi-file store actions', () => { store .dispatch('createTempEntry', { - branchId: store.state.currentBranchId, name: 'testing', type: 'tree', }) @@ -197,7 +194,6 @@ describe('Multi-file store actions', () => { store .dispatch('createTempEntry', { name, - branchId: 'mybranch', type: 'blob', }) .then(() => { @@ -217,7 +213,6 @@ describe('Multi-file store actions', () => { store .dispatch('createTempEntry', { name, - branchId: 'mybranch', type: 'blob', }) .then(() => { @@ -237,7 +232,6 @@ describe('Multi-file store actions', () => { store .dispatch('createTempEntry', { name, - branchId: 'mybranch', type: 'blob', }) .then(() => { @@ -249,7 +243,7 @@ describe('Multi-file store actions', () => { }); it('sets tmp file as active', () => { - createTempEntry(store, { name: 'test', branchId: 'mybranch', type: 'blob' }); + createTempEntry(store, { name: 'test', type: 'blob' }); expect(store.dispatch).toHaveBeenCalledWith('setFileActive', 'test'); }); @@ -262,7 +256,6 @@ describe('Multi-file store actions', () => { store .dispatch('createTempEntry', { name: 'test', - branchId: 'mybranch', type: 'blob', }) .then(() => { @@ -780,9 +773,11 @@ describe('Multi-file store actions', () => { }); it('routes to the renamed file if the original file has been opened', done => { + store.state.currentProjectId = 'test/test'; + store.state.currentBranchId = 'master'; + Object.assign(store.state.entries.orig, { opened: true, - url: '/foo-bar.md', }); store @@ -792,7 +787,7 @@ describe('Multi-file store actions', () => { }) .then(() => { expect(router.push.mock.calls).toHaveLength(1); - expect(router.push).toHaveBeenCalledWith(`/project/foo-bar.md`); + expect(router.push).toHaveBeenCalledWith(`/project/test/test/tree/master/-/renamed/`); }) .then(done) .catch(done.fail); diff --git a/spec/frontend/ide/stores/getters_spec.js b/spec/frontend/ide/stores/getters_spec.js index dcf05329ce0..e24f08fa802 100644 --- a/spec/frontend/ide/stores/getters_spec.js +++ b/spec/frontend/ide/stores/getters_spec.js @@ -1,3 +1,4 @@ +import { TEST_HOST } from 'helpers/test_constants'; import * as getters from '~/ide/stores/getters'; import { createStore } from '~/ide/stores'; import { file } from '../helpers'; @@ -482,4 +483,48 @@ describe('IDE store getters', () => { expect(localStore.getters.getAvailableFileName('foo-bar1.jpg')).toBe('foo-bar1.jpg'); }); }); + + describe('getUrlForPath', () => { + it('returns a route url for the given path', () => { + localState.currentProjectId = 'test/test'; + localState.currentBranchId = 'master'; + + expect(localStore.getters.getUrlForPath('path/to/foo/bar-1.jpg')).toBe( + `/project/test/test/tree/master/-/path/to/foo/bar-1.jpg/`, + ); + }); + }); + + describe('getJsonSchemaForPath', () => { + beforeEach(() => { + localState.currentProjectId = 'path/to/some/project'; + localState.currentBranchId = 'master'; + }); + + it('returns a json schema uri and match config for a json/yaml file that can be loaded by monaco', () => { + expect(localStore.getters.getJsonSchemaForPath('.gitlab-ci.yml')).toEqual({ + fileMatch: ['*.gitlab-ci.yml'], + uri: `${TEST_HOST}/path/to/some/project/-/schema/master/.gitlab-ci.yml`, + }); + }); + + it('returns a path containing sha if branch details are present in state', () => { + localState.projects['path/to/some/project'] = { + name: 'project', + branches: { + master: { + name: 'master', + commit: { + id: 'abcdef123456', + }, + }, + }, + }; + + expect(localStore.getters.getJsonSchemaForPath('.gitlab-ci.yml')).toEqual({ + fileMatch: ['*.gitlab-ci.yml'], + uri: `${TEST_HOST}/path/to/some/project/-/schema/abcdef123456/.gitlab-ci.yml`, + }); + }); + }); }); diff --git a/spec/frontend/ide/stores/integration_spec.js b/spec/frontend/ide/stores/integration_spec.js index f95f036f572..b6a7c7fd02d 100644 --- a/spec/frontend/ide/stores/integration_spec.js +++ b/spec/frontend/ide/stores/integration_spec.js @@ -36,8 +36,6 @@ describe('IDE store integration', () => { beforeEach(() => { const { entries, treeList } = decorateFiles({ data: [`${TEST_PATH_DIR}/`, TEST_PATH, 'README.md'], - projectId: TEST_PROJECT_ID, - branchId: TEST_BRANCH, }); Object.assign(entries[TEST_PATH], { diff --git a/spec/frontend/ide/stores/modules/commit/actions_spec.js b/spec/frontend/ide/stores/modules/commit/actions_spec.js index a14879112fd..babc50e54f1 100644 --- a/spec/frontend/ide/stores/modules/commit/actions_spec.js +++ b/spec/frontend/ide/stores/modules/commit/actions_spec.js @@ -9,6 +9,7 @@ import eventHub from '~/ide/eventhub'; import consts from '~/ide/stores/modules/commit/constants'; import * as mutationTypes from '~/ide/stores/modules/commit/mutation_types'; import * as actions from '~/ide/stores/modules/commit/actions'; +import { createUnexpectedCommitError } from '~/ide/lib/errors'; import { commitActionTypes, PERMISSION_CREATE_MR } from '~/ide/constants'; import testAction from '../../../../helpers/vuex_action_helper'; @@ -510,7 +511,7 @@ describe('IDE commit module actions', () => { }); }); - describe('failed', () => { + describe('success response with failed message', () => { beforeEach(() => { jest.spyOn(service, 'commit').mockResolvedValue({ data: { @@ -533,6 +534,25 @@ describe('IDE commit module actions', () => { }); }); + describe('failed response', () => { + beforeEach(() => { + jest.spyOn(service, 'commit').mockRejectedValue({}); + }); + + it('commits error updates', async () => { + jest.spyOn(store, 'commit'); + + await store.dispatch('commit/commitChanges').catch(() => {}); + + expect(store.commit.mock.calls).toEqual([ + ['commit/CLEAR_ERROR', undefined, undefined], + ['commit/UPDATE_LOADING', true, undefined], + ['commit/UPDATE_LOADING', false, undefined], + ['commit/SET_ERROR', createUnexpectedCommitError(), undefined], + ]); + }); + }); + describe('first commit of a branch', () => { const COMMIT_RESPONSE = { id: '123456', diff --git a/spec/frontend/ide/stores/modules/commit/mutations_spec.js b/spec/frontend/ide/stores/modules/commit/mutations_spec.js index 45ac1a86ab3..6393a70eac6 100644 --- a/spec/frontend/ide/stores/modules/commit/mutations_spec.js +++ b/spec/frontend/ide/stores/modules/commit/mutations_spec.js @@ -1,5 +1,6 @@ import commitState from '~/ide/stores/modules/commit/state'; import mutations from '~/ide/stores/modules/commit/mutations'; +import * as types from '~/ide/stores/modules/commit/mutation_types'; describe('IDE commit module mutations', () => { let state; @@ -62,4 +63,24 @@ describe('IDE commit module mutations', () => { expect(state.shouldCreateMR).toBe(false); }); }); + + describe(types.CLEAR_ERROR, () => { + it('should clear commitError', () => { + state.commitError = {}; + + mutations[types.CLEAR_ERROR](state); + + expect(state.commitError).toBeNull(); + }); + }); + + describe(types.SET_ERROR, () => { + it('should set commitError', () => { + const error = { title: 'foo' }; + + mutations[types.SET_ERROR](state, error); + + expect(state.commitError).toBe(error); + }); + }); }); diff --git a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js index 71918e7e2c2..8511843cc92 100644 --- a/spec/frontend/ide/stores/modules/pipelines/actions_spec.js +++ b/spec/frontend/ide/stores/modules/pipelines/actions_spec.js @@ -15,10 +15,10 @@ import { fetchJobs, toggleStageCollapsed, setDetailJob, - requestJobTrace, - receiveJobTraceError, - receiveJobTraceSuccess, - fetchJobTrace, + requestJobLogs, + receiveJobLogsError, + receiveJobLogsSuccess, + fetchJobLogs, resetLatestPipeline, } from '~/ide/stores/modules/pipelines/actions'; import state from '~/ide/stores/modules/pipelines/state'; @@ -324,24 +324,24 @@ describe('IDE pipelines actions', () => { }); }); - describe('requestJobTrace', () => { + describe('requestJobLogs', () => { it('commits request', done => { - testAction(requestJobTrace, null, mockedState, [{ type: types.REQUEST_JOB_TRACE }], [], done); + testAction(requestJobLogs, null, mockedState, [{ type: types.REQUEST_JOB_LOGS }], [], done); }); }); - describe('receiveJobTraceError', () => { + describe('receiveJobLogsError', () => { it('commits error', done => { testAction( - receiveJobTraceError, + receiveJobLogsError, null, mockedState, - [{ type: types.RECEIVE_JOB_TRACE_ERROR }], + [{ type: types.RECEIVE_JOB_LOGS_ERROR }], [ { type: 'setErrorMessage', payload: { - text: 'An error occurred while fetching the job trace.', + text: 'An error occurred while fetching the job logs.', action: expect.any(Function), actionText: 'Please try again', actionPayload: null, @@ -353,20 +353,20 @@ describe('IDE pipelines actions', () => { }); }); - describe('receiveJobTraceSuccess', () => { + describe('receiveJobLogsSuccess', () => { it('commits data', done => { testAction( - receiveJobTraceSuccess, + receiveJobLogsSuccess, 'data', mockedState, - [{ type: types.RECEIVE_JOB_TRACE_SUCCESS, payload: 'data' }], + [{ type: types.RECEIVE_JOB_LOGS_SUCCESS, payload: 'data' }], [], done, ); }); }); - describe('fetchJobTrace', () => { + describe('fetchJobLogs', () => { beforeEach(() => { mockedState.detailJob = { path: `${TEST_HOST}/project/builds` }; }); @@ -379,20 +379,20 @@ describe('IDE pipelines actions', () => { it('dispatches request', done => { testAction( - fetchJobTrace, + fetchJobLogs, null, mockedState, [], [ - { type: 'requestJobTrace' }, - { type: 'receiveJobTraceSuccess', payload: { html: 'html' } }, + { type: 'requestJobLogs' }, + { type: 'receiveJobLogsSuccess', payload: { html: 'html' } }, ], done, ); }); it('sends get request to correct URL', () => { - fetchJobTrace({ + fetchJobLogs({ state: mockedState, dispatch() {}, @@ -410,11 +410,11 @@ describe('IDE pipelines actions', () => { it('dispatches error', done => { testAction( - fetchJobTrace, + fetchJobLogs, null, mockedState, [], - [{ type: 'requestJobTrace' }, { type: 'receiveJobTraceError' }], + [{ type: 'requestJobLogs' }, { type: 'receiveJobLogsError' }], done, ); }); diff --git a/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js b/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js index 3b7f92cfa74..7d2f5d5d710 100644 --- a/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js +++ b/spec/frontend/ide/stores/modules/pipelines/mutations_spec.js @@ -175,37 +175,37 @@ describe('IDE pipelines mutations', () => { }); }); - describe('REQUEST_JOB_TRACE', () => { + describe('REQUEST_JOB_LOGS', () => { beforeEach(() => { mockedState.detailJob = { ...jobs[0] }; }); it('sets loading on detail job', () => { - mutations[types.REQUEST_JOB_TRACE](mockedState); + mutations[types.REQUEST_JOB_LOGS](mockedState); expect(mockedState.detailJob.isLoading).toBe(true); }); }); - describe('RECEIVE_JOB_TRACE_ERROR', () => { + describe('RECEIVE_JOB_LOGS_ERROR', () => { beforeEach(() => { mockedState.detailJob = { ...jobs[0], isLoading: true }; }); it('sets loading to false on detail job', () => { - mutations[types.RECEIVE_JOB_TRACE_ERROR](mockedState); + mutations[types.RECEIVE_JOB_LOGS_ERROR](mockedState); expect(mockedState.detailJob.isLoading).toBe(false); }); }); - describe('RECEIVE_JOB_TRACE_SUCCESS', () => { + describe('RECEIVE_JOB_LOGS_SUCCESS', () => { beforeEach(() => { mockedState.detailJob = { ...jobs[0], isLoading: true }; }); it('sets output on detail job', () => { - mutations[types.RECEIVE_JOB_TRACE_SUCCESS](mockedState, { html: 'html' }); + mutations[types.RECEIVE_JOB_LOGS_SUCCESS](mockedState, { html: 'html' }); expect(mockedState.detailJob.output).toBe('html'); expect(mockedState.detailJob.isLoading).toBe(false); }); diff --git a/spec/frontend/ide/stores/mutations/file_spec.js b/spec/frontend/ide/stores/mutations/file_spec.js index ff904bbc9cd..b53e40be980 100644 --- a/spec/frontend/ide/stores/mutations/file_spec.js +++ b/spec/frontend/ide/stores/mutations/file_spec.js @@ -61,13 +61,11 @@ describe('IDE store file mutations', () => { mutations.SET_FILE_DATA(localState, { data: { raw_path: 'raw', - binary: true, }, file: localFile, }); expect(localFile.rawPath).toBe('raw'); - expect(localFile.binary).toBeTruthy(); expect(localFile.raw).toBeNull(); expect(localFile.baseRaw).toBeNull(); }); diff --git a/spec/frontend/ide/stores/mutations_spec.js b/spec/frontend/ide/stores/mutations_spec.js index 1b29648fb8b..09e9481e5d4 100644 --- a/spec/frontend/ide/stores/mutations_spec.js +++ b/spec/frontend/ide/stores/mutations_spec.js @@ -113,8 +113,6 @@ describe('Multi-file store mutations', () => { }, treeList: [tmpFile], }, - projectId: 'gitlab-ce', - branchId: 'master', }); expect(localState.trees['gitlab-ce/master'].tree.length).toEqual(1); @@ -272,7 +270,6 @@ describe('Multi-file store mutations', () => { prevId: undefined, prevPath: undefined, prevName: undefined, - prevUrl: undefined, prevKey: undefined, }), ); @@ -337,7 +334,6 @@ describe('Multi-file store mutations', () => { }; Object.assign(localState.entries['root-folder/oldPath'], { parentPath: 'root-folder', - url: 'root-folder/oldPath-blob-root-folder/oldPath', }); mutations.RENAME_ENTRY(localState, { @@ -366,9 +362,6 @@ describe('Multi-file store mutations', () => { }); it('renames entry, preserving old parameters', () => { - Object.assign(localState.entries.oldPath, { - url: `project/-/oldPath`, - }); const oldPathData = localState.entries.oldPath; mutations.RENAME_ENTRY(localState, { @@ -382,12 +375,10 @@ describe('Multi-file store mutations', () => { id: 'newPath', path: 'newPath', name: 'newPath', - url: `project/-/newPath`, key: expect.stringMatching('newPath'), prevId: 'oldPath', prevName: 'oldPath', prevPath: 'oldPath', - prevUrl: `project/-/oldPath`, prevKey: oldPathData.key, prevParentPath: oldPathData.parentPath, }); @@ -409,7 +400,6 @@ describe('Multi-file store mutations', () => { prevId: expect.anything(), prevName: expect.anything(), prevPath: expect.anything(), - prevUrl: expect.anything(), prevKey: expect.anything(), prevParentPath: expect.anything(), }), @@ -419,7 +409,7 @@ describe('Multi-file store mutations', () => { it('properly handles files with spaces in name', () => { const path = 'my fancy path'; const newPath = 'new path'; - const oldEntry = { ...file(path, path, 'blob'), url: `project/-/${path}` }; + const oldEntry = file(path, path, 'blob'); localState.entries[path] = oldEntry; @@ -435,12 +425,10 @@ describe('Multi-file store mutations', () => { id: newPath, path: newPath, name: newPath, - url: `project/-/new path`, key: expect.stringMatching(newPath), prevId: path, prevName: path, prevPath: path, - prevUrl: `project/-/my fancy path`, prevKey: oldEntry.key, prevParentPath: oldEntry.parentPath, }); @@ -549,7 +537,7 @@ describe('Multi-file store mutations', () => { it('correctly saves original values if an entry is renamed multiple times', () => { const original = { ...localState.entries.oldPath }; - const paramsToCheck = ['prevId', 'prevPath', 'prevName', 'prevUrl']; + const paramsToCheck = ['prevId', 'prevPath', 'prevName']; const expectedObj = paramsToCheck.reduce( (o, param) => ({ ...o, [param]: original[param.replace('prev', '').toLowerCase()] }), {}, @@ -577,7 +565,6 @@ describe('Multi-file store mutations', () => { prevId: 'lorem/orig', prevPath: 'lorem/orig', prevName: 'orig', - prevUrl: 'project/-/loren/orig', prevKey: 'lorem/orig', prevParentPath: 'lorem', }; @@ -602,7 +589,6 @@ describe('Multi-file store mutations', () => { prevId: undefined, prevPath: undefined, prevName: undefined, - prevUrl: undefined, prevKey: undefined, prevParentPath: undefined, }), diff --git a/spec/frontend/ide/sync_router_and_store_spec.js b/spec/frontend/ide/sync_router_and_store_spec.js index ccf6e200806..20fd77c4dfb 100644 --- a/spec/frontend/ide/sync_router_and_store_spec.js +++ b/spec/frontend/ide/sync_router_and_store_spec.js @@ -17,9 +17,13 @@ describe('~/ide/sync_router_and_store', () => { const getRouterCurrentPath = () => router.currentRoute.fullPath; const getStoreCurrentPath = () => store.state.router.fullPath; - const updateRouter = path => { + const updateRouter = async path => { + if (getRouterCurrentPath() === path) { + return; + } + router.push(path); - return waitForPromises(); + await waitForPromises(); }; const updateStore = path => { store.dispatch('router/push', path); diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index e7ef0de45a0..97dc8217ecc 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -2,7 +2,7 @@ import { languages } from 'monaco-editor'; import { isTextFile, registerLanguages, - registerSchemas, + registerSchema, trimPathComponents, insertFinalNewline, trimTrailingWhitespace, @@ -13,60 +13,78 @@ import { describe('WebIDE utils', () => { describe('isTextFile', () => { - it('returns false for known binary types', () => { - expect(isTextFile('file content', 'image/png', 'my.png')).toBeFalsy(); - // mime types are case insensitive - expect(isTextFile('file content', 'IMAGE/PNG', 'my.png')).toBeFalsy(); + it.each` + mimeType | name | type | result + ${'image/png'} | ${'my.png'} | ${'binary'} | ${false} + ${'IMAGE/PNG'} | ${'my.png'} | ${'binary'} | ${false} + ${'text/plain'} | ${'my.txt'} | ${'text'} | ${true} + ${'TEXT/PLAIN'} | ${'my.txt'} | ${'text'} | ${true} + `('returns $result for known $type types', ({ mimeType, name, result }) => { + expect(isTextFile({ content: 'file content', mimeType, name })).toBe(result); }); - it('returns true for known text types', () => { - expect(isTextFile('file content', 'text/plain', 'my.txt')).toBeTruthy(); - // mime types are case insensitive - expect(isTextFile('file content', 'TEXT/PLAIN', 'my.txt')).toBeTruthy(); - }); + it.each` + content | mimeType | name + ${'{"éêė":"value"}'} | ${'application/json'} | ${'my.json'} + ${'{"éêė":"value"}'} | ${'application/json'} | ${'.tsconfig'} + ${'SELECT "éêė" from tablename'} | ${'application/sql'} | ${'my.sql'} + ${'{"éêė":"value"}'} | ${'application/json'} | ${'MY.JSON'} + ${'SELECT "éêė" from tablename'} | ${'application/sql'} | ${'MY.SQL'} + ${'var code = "something"'} | ${'application/javascript'} | ${'Gruntfile'} + ${'MAINTAINER Александр "a21283@me.com"'} | ${'application/octet-stream'} | ${'dockerfile'} + `( + 'returns true for file extensions that Monaco supports syntax highlighting for', + ({ content, mimeType, name }) => { + expect(isTextFile({ content, mimeType, name })).toBe(true); + }, + ); - it('returns true for file extensions that Monaco supports syntax highlighting for', () => { - // test based on both MIME and extension - expect(isTextFile('{"éêė":"value"}', 'application/json', 'my.json')).toBeTruthy(); - expect(isTextFile('{"éêė":"value"}', 'application/json', '.tsconfig')).toBeTruthy(); - expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'my.sql')).toBeTruthy(); + it('returns false if filename is same as the expected extension', () => { + expect( + isTextFile({ + name: 'sql', + content: 'SELECT "éêė" from tablename', + mimeType: 'application/sql', + }), + ).toBeFalsy(); }); - it('returns true even irrespective of whether the mimes, extensions or file names are lowercase or upper case', () => { - expect(isTextFile('{"éêė":"value"}', 'application/json', 'MY.JSON')).toBeTruthy(); - expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'MY.SQL')).toBeTruthy(); - expect( - isTextFile('var code = "something"', 'application/javascript', 'Gruntfile'), - ).toBeTruthy(); + it('returns true for ASCII only content for unknown types', () => { expect( - isTextFile( - 'MAINTAINER Александр "alexander11354322283@me.com"', - 'application/octet-stream', - 'dockerfile', - ), + isTextFile({ + name: 'hello.mytype', + content: 'plain text', + mimeType: 'application/x-new-type', + }), ).toBeTruthy(); }); - it('returns false if filename is same as the expected extension', () => { - expect(isTextFile('SELECT "éêė" from tablename', 'application/sql', 'sql')).toBeFalsy(); - }); - - it('returns true for ASCII only content for unknown types', () => { - expect(isTextFile('plain text', 'application/x-new-type', 'hello.mytype')).toBeTruthy(); + it('returns false for non-ASCII content for unknown types', () => { + expect( + isTextFile({ + name: 'my.random', + content: '{"éêė":"value"}', + mimeType: 'application/octet-stream', + }), + ).toBeFalsy(); }); - it('returns true for relevant filenames', () => { - expect( - isTextFile( - 'MAINTAINER Александр "alexander11354322283@me.com"', - 'application/octet-stream', - 'Dockerfile', - ), - ).toBeTruthy(); + it.each` + name | result + ${'myfile.txt'} | ${true} + ${'Dockerfile'} | ${true} + ${'img.png'} | ${false} + ${'abc.js'} | ${true} + ${'abc.random'} | ${false} + ${'image.jpeg'} | ${false} + `('returns $result for $filename when no content or mimeType is passed', ({ name, result }) => { + expect(isTextFile({ name })).toBe(result); }); - it('returns false for non-ASCII content for unknown types', () => { - expect(isTextFile('{"éêė":"value"}', 'application/octet-stream', 'my.random')).toBeFalsy(); + it('returns true if content is empty string but false if content is not passed', () => { + expect(isTextFile({ name: 'abc.dat' })).toBe(false); + expect(isTextFile({ name: 'abc.dat', content: '' })).toBe(true); + expect(isTextFile({ name: 'abc.dat', content: ' ' })).toBe(true); }); }); @@ -159,55 +177,37 @@ describe('WebIDE utils', () => { }); }); - describe('registerSchemas', () => { - let options; + describe('registerSchema', () => { + let schema; beforeEach(() => { - options = { - validate: true, - enableSchemaRequest: true, - hover: true, - completion: true, - schemas: [ - { - uri: 'http://myserver/foo-schema.json', - fileMatch: ['*'], - schema: { - id: 'http://myserver/foo-schema.json', - type: 'object', - properties: { - p1: { enum: ['v1', 'v2'] }, - p2: { $ref: 'http://myserver/bar-schema.json' }, - }, - }, - }, - { - uri: 'http://myserver/bar-schema.json', - schema: { - id: 'http://myserver/bar-schema.json', - type: 'object', - properties: { q1: { enum: ['x1', 'x2'] } }, - }, + schema = { + uri: 'http://myserver/foo-schema.json', + fileMatch: ['*'], + schema: { + id: 'http://myserver/foo-schema.json', + type: 'object', + properties: { + p1: { enum: ['v1', 'v2'] }, + p2: { $ref: 'http://myserver/bar-schema.json' }, }, - ], + }, }; jest.spyOn(languages.json.jsonDefaults, 'setDiagnosticsOptions'); jest.spyOn(languages.yaml.yamlDefaults, 'setDiagnosticsOptions'); }); - it.each` - language | defaultsObj - ${'json'} | ${languages.json.jsonDefaults} - ${'yaml'} | ${languages.yaml.yamlDefaults} - `( - 'registers the given schemas with monaco for lang: $language', - ({ language, defaultsObj }) => { - registerSchemas({ language, options }); + it('registers the given schemas with monaco for both json and yaml languages', () => { + registerSchema(schema); - expect(defaultsObj.setDiagnosticsOptions).toHaveBeenCalledWith(options); - }, - ); + expect(languages.json.jsonDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expect.objectContaining({ schemas: [schema] }), + ); + expect(languages.yaml.yamlDefaults.setDiagnosticsOptions).toHaveBeenCalledWith( + expect.objectContaining({ schemas: [schema] }), + ); + }); }); describe('trimTrailingWhitespace', () => { diff --git a/spec/frontend/import_projects/components/bitbucket_status_table_spec.js b/spec/frontend/import_projects/components/bitbucket_status_table_spec.js index 132ccd0e324..b65b388fd5f 100644 --- a/spec/frontend/import_projects/components/bitbucket_status_table_spec.js +++ b/spec/frontend/import_projects/components/bitbucket_status_table_spec.js @@ -33,7 +33,7 @@ describe('BitbucketStatusTable', () => { it('renders import table component', () => { createComponent({ providerTitle: 'Test' }); - expect(wrapper.contains(ImportProjectsTable)).toBe(true); + expect(wrapper.find(ImportProjectsTable).exists()).toBe(true); }); it('passes alert in incompatible-repos-warning slot', () => { diff --git a/spec/frontend/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_projects/components/import_projects_table_spec.js index b217242968a..1dbad588ec4 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -1,15 +1,12 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { GlLoadingIcon, GlButton, GlIntersectionObserver } from '@gitlab/ui'; import state from '~/import_projects/store/state'; import * as getters from '~/import_projects/store/getters'; import { STATUSES } from '~/import_projects/constants'; import ImportProjectsTable from '~/import_projects/components/import_projects_table.vue'; -import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; -import IncompatibleRepoTableRow from '~/import_projects/components/incompatible_repo_table_row.vue'; -import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue'; describe('ImportProjectsTable', () => { let wrapper; @@ -18,16 +15,26 @@ describe('ImportProjectsTable', () => { wrapper.find('input[data-qa-selector="githubish_import_filter_field"]'); const providerTitle = 'THE PROVIDER'; - const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; + const providerRepo = { + importSource: { + id: 10, + sanitizedName: 'sanitizedName', + fullName: 'fullName', + }, + importedProject: null, + }; const findImportAllButton = () => wrapper .findAll(GlButton) .filter(w => w.props().variant === 'success') .at(0); + const findImportAllModal = () => wrapper.find({ ref: 'importAllModal' }); const importAllFn = jest.fn(); + const importAllModalShowFn = jest.fn(); const setPageFn = jest.fn(); + const fetchReposFn = jest.fn(); function createComponent({ state: initialState, @@ -46,7 +53,7 @@ describe('ImportProjectsTable', () => { ...customGetters, }, actions: { - fetchRepos: jest.fn(), + fetchRepos: fetchReposFn, fetchJobs: jest.fn(), fetchNamespaces: jest.fn(), importAll: importAllFn, @@ -66,6 +73,9 @@ describe('ImportProjectsTable', () => { paginatable, }, slots, + stubs: { + GlModal: { template: '<div>Modal!</div>', methods: { show: importAllModalShowFn } }, + }, }); } @@ -79,58 +89,54 @@ describe('ImportProjectsTable', () => { it('renders a loading icon while repos are loading', () => { createComponent({ state: { isLoadingRepos: true } }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); it('renders a loading icon while namespaces are loading', () => { createComponent({ state: { isLoadingNamespaces: true } }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - it('renders a table with imported projects and provider repos', () => { + it('renders a table with provider repos', () => { + const repositories = [ + { importSource: { id: 1 }, importedProject: null }, + { importSource: { id: 2 }, importedProject: { importStatus: STATUSES.FINISHED } }, + { importSource: { id: 3, incompatible: true }, importedProject: {} }, + ]; + createComponent({ - state: { - namespaces: [{ fullPath: 'path' }], - repositories: [ - { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, - { importSource: { id: 2 }, importedProject: {}, importStatus: STATUSES.FINISHED }, - { - importSource: { id: 3, incompatible: true }, - importedProject: {}, - importStatus: STATUSES.NONE, - }, - ], - }, + state: { namespaces: [{ fullPath: 'path' }], repositories }, }); - expect(wrapper.contains(GlLoadingIcon)).toBe(false); - expect(wrapper.contains('table')).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); + expect(wrapper.find('table').exists()).toBe(true); expect( wrapper .findAll('th') .filter(w => w.text() === `From ${providerTitle}`) - .isEmpty(), - ).toBe(false); + .exists(), + ).toBe(true); - expect(wrapper.contains(ProviderRepoTableRow)).toBe(true); - expect(wrapper.contains(ImportedProjectTableRow)).toBe(true); - expect(wrapper.contains(IncompatibleRepoTableRow)).toBe(true); + expect(wrapper.findAll(ProviderRepoTableRow)).toHaveLength(repositories.length); }); it.each` - hasIncompatibleRepos | buttonText - ${false} | ${'Import all repositories'} - ${true} | ${'Import all compatible repositories'} + hasIncompatibleRepos | count | buttonText + ${false} | ${1} | ${'Import 1 repository'} + ${true} | ${1} | ${'Import 1 compatible repository'} + ${false} | ${5} | ${'Import 5 repositories'} + ${true} | ${5} | ${'Import 5 compatible repositories'} `( - 'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos', - ({ hasIncompatibleRepos, buttonText }) => { + 'import all button has "$buttonText" text when hasIncompatibleRepos is $hasIncompatibleRepos and repos count is $count', + ({ hasIncompatibleRepos, buttonText, count }) => { createComponent({ state: { providerRepos: [providerRepo], }, getters: { hasIncompatibleRepos: () => hasIncompatibleRepos, + importAllCount: () => count, }, }); @@ -138,20 +144,28 @@ describe('ImportProjectsTable', () => { }, ); - it('renders an empty state if there are no projects available', () => { + it('renders an empty state if there are no repositories available', () => { createComponent({ state: { repositories: [] } }); - expect(wrapper.contains(ProviderRepoTableRow)).toBe(false); - expect(wrapper.contains(ImportedProjectTableRow)).toBe(false); + expect(wrapper.find(ProviderRepoTableRow).exists()).toBe(false); expect(wrapper.text()).toContain(`No ${providerTitle} repositories found`); }); - it('sends importAll event when import button is clicked', async () => { - createComponent({ state: { providerRepos: [providerRepo] } }); + it('opens confirmation modal when import all button is clicked', async () => { + createComponent({ state: { repositories: [providerRepo] } }); findImportAllButton().vm.$emit('click'); await nextTick(); + expect(importAllModalShowFn).toHaveBeenCalled(); + }); + + it('triggers importAll action when modal is confirmed', async () => { + createComponent({ state: { providerRepos: [providerRepo] } }); + + findImportAllModal().vm.$emit('ok'); + await nextTick(); + expect(importAllFn).toHaveBeenCalled(); }); @@ -189,21 +203,29 @@ describe('ImportProjectsTable', () => { }); }); - it('passes current page to page-query-param-sync component', () => { - expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page); + it('does not call fetchRepos on mount', () => { + expect(fetchReposFn).not.toHaveBeenCalled(); + }); + + it('renders intersection observer component', () => { + expect(wrapper.find(GlIntersectionObserver).exists()).toBe(true); }); - it('dispatches setPage when page-query-param-sync emits popstate', () => { - const NEW_PAGE = 2; - wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE); + it('calls fetchRepos when intersection observer appears', async () => { + wrapper.find(GlIntersectionObserver).vm.$emit('appear'); - const { calls } = setPageFn.mock; + await nextTick(); - expect(calls).toHaveLength(1); - expect(calls[0][1]).toBe(NEW_PAGE); + expect(fetchReposFn).toHaveBeenCalled(); }); }); + it('calls fetchRepos on mount', () => { + createComponent(); + + expect(fetchReposFn).toHaveBeenCalled(); + }); + it.each` hasIncompatibleRepos | shouldRenderSlot | action ${false} | ${false} | ${'does not render'} diff --git a/spec/frontend/import_projects/components/imported_project_table_row_spec.js b/spec/frontend/import_projects/components/imported_project_table_row_spec.js deleted file mode 100644 index 8890c352826..00000000000 --- a/spec/frontend/import_projects/components/imported_project_table_row_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { mount } from '@vue/test-utils'; -import ImportedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; -import ImportStatus from '~/import_projects/components/import_status.vue'; -import { STATUSES } from '~/import_projects/constants'; - -describe('ImportedProjectTableRow', () => { - let wrapper; - const project = { - importSource: { - fullName: 'fullName', - providerLink: 'providerLink', - }, - importedProject: { - id: 1, - fullPath: 'fullPath', - importSource: 'importSource', - }, - importStatus: STATUSES.FINISHED, - }; - - function mountComponent() { - wrapper = mount(ImportedProjectTableRow, { propsData: { project } }); - } - - beforeEach(() => { - mountComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders an imported project table row', () => { - const providerLink = wrapper.find('[data-testid=providerLink]'); - - expect(providerLink.attributes().href).toMatch(project.importSource.providerLink); - expect(providerLink.text()).toMatch(project.importSource.fullName); - expect(wrapper.find('[data-testid=fullPath]').text()).toMatch(project.importedProject.fullPath); - expect(wrapper.find(ImportStatus).props().status).toBe(project.importStatus); - expect(wrapper.find('[data-testid=goToProject').attributes().href).toMatch( - project.importedProject.fullPath, - ); - }); -}); diff --git a/spec/frontend/import_projects/components/page_query_param_sync_spec.js b/spec/frontend/import_projects/components/page_query_param_sync_spec.js deleted file mode 100644 index be19ecca1ba..00000000000 --- a/spec/frontend/import_projects/components/page_query_param_sync_spec.js +++ /dev/null @@ -1,87 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; -import { TEST_HOST } from 'helpers/test_constants'; - -import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue'; - -describe('PageQueryParamSync', () => { - let originalPushState; - let originalAddEventListener; - let originalRemoveEventListener; - - const pushStateMock = jest.fn(); - const addEventListenerMock = jest.fn(); - const removeEventListenerMock = jest.fn(); - - beforeAll(() => { - window.location.search = ''; - originalPushState = window.pushState; - - window.history.pushState = pushStateMock; - - originalAddEventListener = window.addEventListener; - window.addEventListener = addEventListenerMock; - - originalRemoveEventListener = window.removeEventListener; - window.removeEventListener = removeEventListenerMock; - }); - - afterAll(() => { - window.history.pushState = originalPushState; - window.addEventListener = originalAddEventListener; - window.removeEventListener = originalRemoveEventListener; - }); - - let wrapper; - beforeEach(() => { - wrapper = shallowMount(PageQueryParamSync, { - propsData: { page: 3 }, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('calls push state with page number when page is updated and differs from 1', async () => { - wrapper.setProps({ page: 2 }); - - await nextTick(); - - const { calls } = pushStateMock.mock; - expect(calls).toHaveLength(1); - expect(calls[0][2]).toBe(`${TEST_HOST}/?page=2`); - }); - - it('calls push state without page number when page is updated and is 1', async () => { - wrapper.setProps({ page: 1 }); - - await nextTick(); - - const { calls } = pushStateMock.mock; - expect(calls).toHaveLength(1); - expect(calls[0][2]).toBe(`${TEST_HOST}/`); - }); - - it('subscribes to popstate event on create', () => { - expect(addEventListenerMock).toHaveBeenCalledWith('popstate', expect.any(Function)); - }); - - it('unsubscribes from popstate event when destroyed', () => { - const [, fn] = addEventListenerMock.mock.calls[0]; - - wrapper.destroy(); - - expect(removeEventListenerMock).toHaveBeenCalledWith('popstate', fn); - }); - - it('emits popstate event when popstate is triggered', async () => { - const [, fn] = addEventListenerMock.mock.calls[0]; - - delete window.location; - window.location = new URL(`${TEST_HOST}/?page=5`); - fn(); - - expect(wrapper.emitted().popstate[0]).toStrictEqual([5]); - }); -}); diff --git a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js index bd9cd07db78..03e30ef610e 100644 --- a/spec/frontend/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_projects/components/provider_repo_table_row_spec.js @@ -1,6 +1,7 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlBadge } from '@gitlab/ui'; import ProviderRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; import ImportStatus from '~/import_projects/components/import_status.vue'; import { STATUSES } from '~/import_projects/constants'; @@ -14,20 +15,6 @@ describe('ProviderRepoTableRow', () => { targetNamespace: 'target', newName: 'newName', }; - const ciCdOnly = false; - const repo = { - importSource: { - id: 'remote-1', - fullName: 'fullName', - providerLink: 'providerLink', - }, - importedProject: { - id: 1, - fullPath: 'fullPath', - importSource: 'importSource', - }, - importStatus: STATUSES.FINISHED, - }; const availableNamespaces = [ { text: 'Groups', children: [{ id: 'test', text: 'test' }] }, @@ -46,55 +33,137 @@ describe('ProviderRepoTableRow', () => { return store; } - const findImportButton = () => - wrapper - .findAll('button') - .filter(node => node.text() === 'Import') - .at(0); + const findImportButton = () => { + const buttons = wrapper.findAll('button').filter(node => node.text() === 'Import'); + + return buttons.length ? buttons.at(0) : buttons; + }; - function mountComponent(initialState) { + function mountComponent(props) { const localVue = createLocalVue(); localVue.use(Vuex); - const store = initStore({ ciCdOnly, ...initialState }); + const store = initStore(); wrapper = shallowMount(ProviderRepoTableRow, { localVue, store, - propsData: { repo, availableNamespaces }, + propsData: { availableNamespaces, ...props }, }); } - beforeEach(() => { - mountComponent(); - }); - afterEach(() => { wrapper.destroy(); + wrapper = null; }); - it('renders a provider repo table row', () => { - const providerLink = wrapper.find('[data-testid=providerLink]'); + describe('when rendering importable project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + }; + + beforeEach(() => { + mountComponent({ repo }); + }); + + it('renders project information', () => { + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + }); + + it('renders empty import status', () => { + expect(wrapper.find(ImportStatus).props().status).toBe(STATUSES.NONE); + }); + + it('renders a select2 namespace select', () => { + expect(wrapper.find(Select2Select).exists()).toBe(true); + expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces); + }); + + it('renders import button', () => { + expect(findImportButton().exists()).toBe(true); + }); + + it('imports repo when clicking import button', async () => { + findImportButton().trigger('click'); + + await nextTick(); + + const { calls } = fetchImport.mock; - expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); - expect(providerLink.text()).toMatch(repo.importSource.fullName); - expect(wrapper.find(ImportStatus).props().status).toBe(repo.importStatus); - expect(wrapper.contains('button')).toBe(true); + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(repo.importSource.id); + }); }); - it('renders a select2 namespace select', () => { - expect(wrapper.contains(Select2Select)).toBe(true); - expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces); + describe('when rendering imported project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + importStatus: STATUSES.FINISHED, + }, + }; + + beforeEach(() => { + mountComponent({ repo }); + }); + + it('renders project information', () => { + const providerLink = wrapper.find('[data-testid=providerLink]'); + + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + }); + + it('renders proper import status', () => { + expect(wrapper.find(ImportStatus).props().status).toBe(repo.importedProject.importStatus); + }); + + it('does not renders a namespace select', () => { + expect(wrapper.find(Select2Select).exists()).toBe(false); + }); + + it('does not render import button', () => { + expect(findImportButton().exists()).toBe(false); + }); }); - it('imports repo when clicking import button', async () => { - findImportButton().trigger('click'); + describe('when rendering incompatible project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + incompatible: true, + }, + }; - await nextTick(); + beforeEach(() => { + mountComponent({ repo }); + }); + + it('renders project information', () => { + const providerLink = wrapper.find('[data-testid=providerLink]'); - const { calls } = fetchImport.mock; + expect(providerLink.attributes().href).toMatch(repo.importSource.providerLink); + expect(providerLink.text()).toMatch(repo.importSource.fullName); + }); - expect(calls).toHaveLength(1); - expect(calls[0][1]).toBe(repo.importSource.id); + it('renders badge with error', () => { + expect(wrapper.find(GlBadge).text()).toBe('Incompatible project'); + }); }); }); diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index 45a59b3f6d6..6951f2bf04d 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -83,7 +83,7 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { + it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { mock.onGet(MOCK_ENDPOINT).reply(200, payload); return testAction( @@ -91,54 +91,65 @@ describe('import_projects store actions', () => { null, localState, [ + { type: SET_PAGE, payload: 1 }, { type: REQUEST_REPOS }, { type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), }, ], - [{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }], + [], ); }); - it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { + it('commits SET_PAGE, REQUEST_REPOS, RECEIVE_REPOS_ERROR and SET_PAGE again mutations on an unsuccessful request', () => { mock.onGet(MOCK_ENDPOINT).reply(500); return testAction( fetchRepos, null, localState, - [{ type: REQUEST_REPOS }, { type: RECEIVE_REPOS_ERROR }], - [{ type: 'stopJobsPolling' }], + [ + { type: SET_PAGE, payload: 1 }, + { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 0 }, + { type: RECEIVE_REPOS_ERROR }, + ], + [], ); }); - describe('when pagination is enabled', () => { - it('includes page in url query params', async () => { - const { fetchRepos: fetchReposWithPagination } = actionsFactory({ - endpoints, - hasPagination: true, - }); + it('includes page in url query params', async () => { + let requestedUrl; + mock.onGet().reply(config => { + requestedUrl = config.url; + return [200, payload]; + }); - let requestedUrl; - mock.onGet().reply(config => { - requestedUrl = config.url; - return [200, payload]; - }); + const localStateWithPage = { ...localState, pageInfo: { page: 2 } }; - await testAction( - fetchReposWithPagination, - null, - localState, - expect.any(Array), - expect.any(Array), - ); + await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array)); - expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`); - }); + expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`); }); - describe('when filtered', () => { + it('correctly updates current page on an unsuccessful request', () => { + mock.onGet(MOCK_ENDPOINT).reply(500); + const CURRENT_PAGE = 5; + + return testAction( + fetchRepos, + null, + { ...localState, pageInfo: { page: CURRENT_PAGE } }, + expect.arrayContaining([ + { type: SET_PAGE, payload: CURRENT_PAGE + 1 }, + { type: SET_PAGE, payload: CURRENT_PAGE }, + ]), + [], + ); + }); + + describe('when /home/xanf/projects/gdk/gitlab/spec/frontend/import_projects/store/actions_spec.jsfiltered', () => { it('fetches repos with filter applied', () => { mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload); @@ -147,13 +158,14 @@ describe('import_projects store actions', () => { null, { ...localState, filter: 'filter' }, [ + { type: SET_PAGE, payload: 1 }, { type: REQUEST_REPOS }, { type: RECEIVE_REPOS_SUCCESS, payload: convertObjectPropsToCamelCase(payload, { deep: true }), }, ], - [{ type: 'stopJobsPolling' }, { type: 'fetchJobs' }], + [], ); }); }); diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_projects/store/getters_spec.js index 5c1ea25a684..1ce42e534ea 100644 --- a/spec/frontend/import_projects/store/getters_spec.js +++ b/spec/frontend/import_projects/store/getters_spec.js @@ -3,6 +3,7 @@ import { isImportingAnyRepo, hasIncompatibleRepos, hasImportableRepos, + importAllCount, getImportTarget, } from '~/import_projects/store/getters'; import { STATUSES } from '~/import_projects/constants'; @@ -10,13 +11,12 @@ import state from '~/import_projects/store/state'; const IMPORTED_REPO = { importSource: {}, - importedProject: { fullPath: 'some/path' }, + importedProject: { fullPath: 'some/path', importStatus: STATUSES.FINISHED }, }; const IMPORTABLE_REPO = { importSource: { id: 'some-id', sanitizedName: 'sanitized' }, importedProject: null, - importStatus: STATUSES.NONE, }; const INCOMPATIBLE_REPO = { @@ -56,14 +56,20 @@ describe('import_projects store getters', () => { ${STATUSES.STARTED} | ${true} ${STATUSES.FINISHED} | ${false} `( - 'isImportingAnyRepo returns $value when repo with $importStatus status is available', + 'isImportingAnyRepo returns $value when project with $importStatus status is available', ({ importStatus, value }) => { - localState.repositories = [{ importStatus }]; + localState.repositories = [{ importedProject: { importStatus } }]; expect(isImportingAnyRepo(localState)).toBe(value); }, ); + it('isImportingAnyRepo returns false when project with no defined importStatus status is available', () => { + localState.repositories = [{ importSource: {} }]; + + expect(isImportingAnyRepo(localState)).toBe(false); + }); + describe('hasIncompatibleRepos', () => { it('returns true if there are any incompatible projects', () => { localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; @@ -92,6 +98,19 @@ describe('import_projects store getters', () => { }); }); + describe('importAllCount', () => { + it('returns count of available importable projects ', () => { + localState.repositories = [ + IMPORTABLE_REPO, + IMPORTABLE_REPO, + IMPORTED_REPO, + INCOMPATIBLE_REPO, + ]; + + expect(importAllCount(localState)).toBe(2); + }); + }); + describe('getImportTarget', () => { it('returns default value if no custom target available', () => { localState.defaultTargetNamespace = 'default'; diff --git a/spec/frontend/import_projects/store/mutations_spec.js b/spec/frontend/import_projects/store/mutations_spec.js index 3672ec9f2c0..5d78a7fa9e7 100644 --- a/spec/frontend/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_projects/store/mutations_spec.js @@ -1,9 +1,11 @@ import * as types from '~/import_projects/store/mutation_types'; import mutations from '~/import_projects/store/mutations'; +import getInitialState from '~/import_projects/store/state'; import { STATUSES } from '~/import_projects/constants'; describe('import_projects store mutations', () => { let state; + const SOURCE_PROJECT = { id: 1, full_name: 'full/name', @@ -19,13 +21,23 @@ describe('import_projects store mutations', () => { }; describe(`${types.SET_FILTER}`, () => { - it('overwrites current filter value', () => { - state = { filter: 'some-value' }; - const NEW_VALUE = 'new-value'; + const NEW_VALUE = 'new-value'; + beforeEach(() => { + state = { + filter: 'some-value', + repositories: ['some', ' repositories'], + pageInfo: { page: 1 }, + }; mutations[types.SET_FILTER](state, NEW_VALUE); + }); - expect(state.filter).toBe(NEW_VALUE); + it('removes current repositories list', () => { + expect(state.repositories.length).toBe(0); + }); + + it('resets current page to 0', () => { + expect(state.pageInfo.page).toBe(0); }); }); @@ -40,93 +52,104 @@ describe('import_projects store mutations', () => { }); describe(`${types.RECEIVE_REPOS_SUCCESS}`, () => { - describe('for imported projects', () => { - const response = { - importedProjects: [IMPORTED_PROJECT], - providerRepos: [], - }; + describe('with legacy response format', () => { + describe('for imported projects', () => { + const response = { + importedProjects: [IMPORTED_PROJECT], + providerRepos: [], + }; - it('picks import status from response', () => { - state = {}; + it('recreates importSource from response', () => { + state = getInitialState(); - mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); - expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus); - }); + expect(state.repositories[0].importSource).toStrictEqual( + expect.objectContaining({ + fullName: IMPORTED_PROJECT.importSource, + sanitizedName: IMPORTED_PROJECT.name, + providerLink: IMPORTED_PROJECT.providerLink, + }), + ); + }); - it('recreates importSource from response', () => { - state = {}; + it('passes project to importProject', () => { + state = getInitialState(); - mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); - expect(state.repositories[0].importSource).toStrictEqual( - expect.objectContaining({ - fullName: IMPORTED_PROJECT.importSource, - sanitizedName: IMPORTED_PROJECT.name, - providerLink: IMPORTED_PROJECT.providerLink, - }), - ); + expect(IMPORTED_PROJECT).toStrictEqual( + expect.objectContaining(state.repositories[0].importedProject), + ); + }); }); - it('passes project to importProject', () => { - state = {}; + describe('for importable projects', () => { + beforeEach(() => { + state = getInitialState(); - mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + const response = { + importedProjects: [], + providerRepos: [SOURCE_PROJECT], + }; + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + }); - expect(IMPORTED_PROJECT).toStrictEqual( - expect.objectContaining(state.repositories[0].importedProject), - ); + it('sets importSource to project', () => { + expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT); + }); }); - }); - describe('for importable projects', () => { - beforeEach(() => { - state = {}; + describe('for incompatible projects', () => { const response = { importedProjects: [], - providerRepos: [SOURCE_PROJECT], + providerRepos: [], + incompatibleRepos: [SOURCE_PROJECT], }; - mutations[types.RECEIVE_REPOS_SUCCESS](state, response); - }); - it('sets import status to none', () => { - expect(state.repositories[0].importStatus).toBe(STATUSES.NONE); - }); + beforeEach(() => { + state = getInitialState(); + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + }); + + it('sets incompatible flag', () => { + expect(state.repositories[0].importSource.incompatible).toBe(true); + }); - it('sets importSource to project', () => { - expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT); + it('sets importSource to project', () => { + expect(state.repositories[0].importSource).toStrictEqual( + expect.objectContaining(SOURCE_PROJECT), + ); + }); }); - }); - describe('for incompatible projects', () => { - const response = { - importedProjects: [], - providerRepos: [], - incompatibleRepos: [SOURCE_PROJECT], - }; + it('sets repos loading flag to false', () => { + const response = { + importedProjects: [], + providerRepos: [], + }; + + state = getInitialState(); - beforeEach(() => { - state = {}; mutations[types.RECEIVE_REPOS_SUCCESS](state, response); - }); - it('sets incompatible flag', () => { - expect(state.repositories[0].importSource.incompatible).toBe(true); + expect(state.isLoadingRepos).toBe(false); }); + }); - it('sets importSource to project', () => { - expect(state.repositories[0].importSource).toStrictEqual( - expect.objectContaining(SOURCE_PROJECT), - ); - }); + it('passes response as it is', () => { + const response = []; + state = getInitialState(); + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.repositories).toStrictEqual(response); }); it('sets repos loading flag to false', () => { - const response = { - importedProjects: [], - providerRepos: [], - }; - state = {}; + const response = []; + + state = getInitialState(); mutations[types.RECEIVE_REPOS_SUCCESS](state, response); @@ -136,7 +159,7 @@ describe('import_projects store mutations', () => { describe(`${types.RECEIVE_REPOS_ERROR}`, () => { it('sets repos loading flag to false', () => { - state = {}; + state = getInitialState(); mutations[types.RECEIVE_REPOS_ERROR](state); @@ -154,7 +177,7 @@ describe('import_projects store mutations', () => { }); it(`sets status to ${STATUSES.SCHEDULING}`, () => { - expect(state.repositories[0].importStatus).toBe(STATUSES.SCHEDULING); + expect(state.repositories[0].importedProject.importStatus).toBe(STATUSES.SCHEDULING); }); }); @@ -170,7 +193,9 @@ describe('import_projects store mutations', () => { }); it('sets import status', () => { - expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus); + expect(state.repositories[0].importedProject.importStatus).toBe( + IMPORTED_PROJECT.importStatus, + ); }); it('sets imported project', () => { @@ -188,8 +213,8 @@ describe('import_projects store mutations', () => { mutations[types.RECEIVE_IMPORT_ERROR](state, REPO_ID); }); - it(`resets import status to ${STATUSES.NONE}`, () => { - expect(state.repositories[0].importStatus).toBe(STATUSES.NONE); + it(`removes importedProject entry`, () => { + expect(state.repositories[0].importedProject).toBeNull(); }); }); @@ -203,7 +228,9 @@ describe('import_projects store mutations', () => { mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects); - expect(state.repositories[0].importStatus).toBe(updatedProjects[0].importStatus); + expect(state.repositories[0].importedProject.importStatus).toBe( + updatedProjects[0].importStatus, + ); }); }); @@ -280,17 +307,6 @@ describe('import_projects store mutations', () => { }); }); - describe(`${types.SET_PAGE_INFO}`, () => { - it('sets passed page info', () => { - state = {}; - const pageInfo = { page: 1, total: 10 }; - - mutations[types.SET_PAGE_INFO](state, pageInfo); - - expect(state.pageInfo).toBe(pageInfo); - }); - }); - describe(`${types.SET_PAGE}`, () => { it('sets page number', () => { const NEW_PAGE = 4; diff --git a/spec/frontend/import_projects/utils_spec.js b/spec/frontend/import_projects/utils_spec.js index 826b06d5a70..4e1e16a3184 100644 --- a/spec/frontend/import_projects/utils_spec.js +++ b/spec/frontend/import_projects/utils_spec.js @@ -1,7 +1,16 @@ -import { isProjectImportable } from '~/import_projects/utils'; +import { isProjectImportable, isIncompatible, getImportStatus } from '~/import_projects/utils'; import { STATUSES } from '~/import_projects/constants'; describe('import_projects utils', () => { + const COMPATIBLE_PROJECT = { + importSource: { incompatible: false }, + }; + + const INCOMPATIBLE_PROJECT = { + importSource: { incompatible: true }, + importedProject: null, + }; + describe('isProjectImportable', () => { it.each` status | result @@ -14,19 +23,43 @@ describe('import_projects utils', () => { `('returns $result when project is compatible and status is $status', ({ status, result }) => { expect( isProjectImportable({ - importStatus: status, - importSource: { incompatible: false }, + ...COMPATIBLE_PROJECT, + importedProject: { importStatus: status }, }), ).toBe(result); }); + it('returns true if import status is not defined', () => { + expect(isProjectImportable({ importSource: {} })).toBe(true); + }); + it('returns false if project is not compatible', () => { + expect(isProjectImportable(INCOMPATIBLE_PROJECT)).toBe(false); + }); + }); + + describe('isIncompatible', () => { + it('returns true for incompatible project', () => { + expect(isIncompatible(INCOMPATIBLE_PROJECT)).toBe(true); + }); + + it('returns false for compatible project', () => { + expect(isIncompatible(COMPATIBLE_PROJECT)).toBe(false); + }); + }); + + describe('getImportStatus', () => { + it('returns actual status when project status is provided', () => { expect( - isProjectImportable({ - importStatus: STATUSES.NONE, - importSource: { incompatible: true }, + getImportStatus({ + ...COMPATIBLE_PROJECT, + importedProject: { importStatus: STATUSES.FINISHED }, }), - ).toBe(false); + ).toBe(STATUSES.FINISHED); + }); + + it('returns NONE as status if import status is not provided', () => { + expect(getImportStatus(COMPATIBLE_PROJECT)).toBe(STATUSES.NONE); }); }); }); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js index 33ddd06d6d9..307806e0a8a 100644 --- a/spec/frontend/incidents/components/incidents_list_spec.js +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -13,6 +13,7 @@ import { } from '@gitlab/ui'; import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility'; import IncidentsList from '~/incidents/components/incidents_list.vue'; +import SeverityToken from '~/sidebar/components/severity/severity.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { I18N, INCIDENT_STATUS_TABS } from '~/incidents/constants'; import mockIncidents from '../mocks/incidents.json'; @@ -30,9 +31,9 @@ describe('Incidents List', () => { const incidentTemplateName = 'incident'; const incidentType = 'incident'; const incidentsCount = { - opened: 14, - closed: 1, - all: 16, + opened: 24, + closed: 10, + all: 26, }; const findTable = () => wrapper.find(GlTable); @@ -51,6 +52,7 @@ describe('Incidents List', () => { const findStatusFilterBadge = () => wrapper.findAll(GlBadge); const findStatusTabs = () => wrapper.find(GlTabs); const findEmptyState = () => wrapper.find(GlEmptyState); + const findSeverity = () => wrapper.findAll(SeverityToken); function mountComponent({ data = { incidents: [], incidentsCount: {} }, loading = false }) { wrapper = mount(IncidentsList, { @@ -78,6 +80,7 @@ describe('Incidents List', () => { stubs: { GlButton: true, GlAvatar: true, + GlEmptyState: true, }, }); } @@ -96,12 +99,30 @@ describe('Incidents List', () => { expect(findLoader().exists()).toBe(true); }); - it('shows empty state', () => { - mountComponent({ - data: { incidents: { list: [] }, incidentsCount: {} }, - loading: false, - }); - expect(findEmptyState().exists()).toBe(true); + describe('empty state', () => { + const { + emptyState: { title, emptyClosedTabTitle, description }, + } = I18N; + + it.each` + statusFilter | all | closed | expectedTitle | expectedDescription + ${'all'} | ${2} | ${1} | ${title} | ${description} + ${'open'} | ${2} | ${0} | ${title} | ${description} + ${'closed'} | ${0} | ${0} | ${title} | ${description} + ${'closed'} | ${2} | ${0} | ${emptyClosedTabTitle} | ${undefined} + `( + `when active tab is $statusFilter and there are $all incidents in total and $closed closed incidents, the empty state + has title: $expectedTitle and description: $expectedDescription`, + ({ statusFilter, all, closed, expectedTitle, expectedDescription }) => { + mountComponent({ + data: { incidents: { list: [] }, incidentsCount: { all, closed }, statusFilter }, + loading: false, + }); + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().attributes('title')).toBe(expectedTitle); + expect(findEmptyState().attributes('description')).toBe(expectedDescription); + }, + ); }); it('shows error state', () => { @@ -163,6 +184,10 @@ describe('Incidents List', () => { ); }); }); + + it('renders severity per row', () => { + expect(findSeverity().length).toBe(mockIncidents.length); + }); }); describe('Create Incident', () => { @@ -188,6 +213,14 @@ describe('Incidents List', () => { expect(findCreateIncidentBtn().attributes('loading')).toBe('true'); }); }); + + it("doesn't show the button when list is empty", () => { + mountComponent({ + data: { incidents: { list: [] }, incidentsCount: {} }, + loading: false, + }); + expect(findCreateIncidentBtn().exists()).toBe(false); + }); }); describe('Pagination', () => { @@ -313,7 +346,7 @@ describe('Incidents List', () => { describe('Status Filter Tabs', () => { beforeEach(() => { mountComponent({ - data: { incidents: mockIncidents, incidentsCount }, + data: { incidents: { list: mockIncidents }, incidentsCount }, loading: false, stubs: { GlTab: true, @@ -345,7 +378,7 @@ describe('Incidents List', () => { describe('sorting the incident list by column', () => { beforeEach(() => { mountComponent({ - data: { incidents: mockIncidents, incidentsCount }, + data: { incidents: { list: mockIncidents }, incidentsCount }, loading: false, }); }); diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json index 4eab709e53f..42b3d6d3eb6 100644 --- a/spec/frontend/incidents/mocks/incidents.json +++ b/spec/frontend/incidents/mocks/incidents.json @@ -4,7 +4,8 @@ "title": "New: Incident", "createdAt": "2020-06-03T15:46:08Z", "assignees": {}, - "state": "opened" + "state": "opened", + "severity": "CRITICAL" }, { "iid": "14", @@ -20,20 +21,23 @@ } ] }, - "state": "opened" + "state": "opened", + "severity": "HIGH" }, { "iid": "13", "title": "Create issue3", "createdAt": "2020-05-19T08:53:55Z", "assignees": {}, - "state": "closed" + "state": "closed", + "severity": "LOW" }, { "iid": "12", "title": "Create issue2", "createdAt": "2020-05-18T17:13:35Z", "assignees": {}, - "state": "closed" + "state": "closed", + "severity": "MEDIUM" } ] diff --git a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap index f3f610e4bb7..cab2165b5db 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -45,7 +45,7 @@ exports[`Alert integration settings form default state should match the default </gl-link-stub> </label> - <gl-new-dropdown-stub + <gl-dropdown-stub block="true" category="tertiary" data-qa-selector="incident_templates_dropdown" @@ -55,7 +55,7 @@ exports[`Alert integration settings form default state should match the default text="selecte_tmpl" variant="default" > - <gl-new-dropdown-item-stub + <gl-dropdown-item-stub avatarurl="" data-qa-selector="incident_templates_item" iconcolor="" @@ -67,8 +67,8 @@ exports[`Alert integration settings form default state should match the default No template selected - </gl-new-dropdown-item-stub> - </gl-new-dropdown-stub> + </gl-dropdown-item-stub> + </gl-dropdown-stub> </gl-form-group-stub> <gl-form-group-stub @@ -81,6 +81,18 @@ exports[`Alert integration settings form default state should match the default </gl-form-checkbox-stub> </gl-form-group-stub> + <gl-form-group-stub + class="gl-pl-0 gl-mb-5" + > + <gl-form-checkbox-stub + checked="true" + > + <span> + Automatically close incident issues when the associated Prometheus alert resolves. + </span> + </gl-form-checkbox-stub> + </gl-form-group-stub> + <div class="gl-display-flex gl-justify-content-end" > diff --git a/spec/frontend/incidents_settings/components/alerts_form_spec.js b/spec/frontend/incidents_settings/components/alerts_form_spec.js index 04832f31e58..2516e8afdfa 100644 --- a/spec/frontend/incidents_settings/components/alerts_form_spec.js +++ b/spec/frontend/incidents_settings/components/alerts_form_spec.js @@ -16,6 +16,7 @@ describe('Alert integration settings form', () => { createIssue: true, sendEmail: false, templates: [], + autoCloseIncident: true, }, }, }); @@ -42,6 +43,7 @@ describe('Alert integration settings form', () => { create_issue: wrapper.vm.createIssueEnabled, issue_template_key: wrapper.vm.issueTemplate, send_email: wrapper.vm.sendEmailEnabled, + auto_close_incident: wrapper.vm.autoCloseIncident, }), ); }); diff --git a/spec/frontend/integrations/edit/components/active_checkbox_spec.js b/spec/frontend/integrations/edit/components/active_checkbox_spec.js new file mode 100644 index 00000000000..38bcb1e0aab --- /dev/null +++ b/spec/frontend/integrations/edit/components/active_checkbox_spec.js @@ -0,0 +1,76 @@ +import { mount } from '@vue/test-utils'; +import { GlFormCheckbox } from '@gitlab/ui'; +import { createStore } from '~/integrations/edit/store'; + +import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; + +describe('ActiveCheckbox', () => { + let wrapper; + + const createComponent = (customStateProps = {}, isInheriting = false) => { + wrapper = mount(ActiveCheckbox, { + store: createStore({ + customState: { ...customStateProps }, + }), + computed: { + isInheriting: () => isInheriting, + }, + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findGlFormCheckbox = () => wrapper.find(GlFormCheckbox); + const findInputInCheckbox = () => findGlFormCheckbox().find('input'); + + describe('template', () => { + describe('is inheriting adminSettings', () => { + it('renders GlFormCheckbox as disabled', () => { + createComponent({}, true); + + expect(findGlFormCheckbox().exists()).toBe(true); + expect(findInputInCheckbox().attributes('disabled')).toBe('disabled'); + }); + }); + + describe('initialActivated is false', () => { + it('renders GlFormCheckbox as unchecked', () => { + createComponent({ + initialActivated: false, + }); + + expect(findGlFormCheckbox().exists()).toBe(true); + expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false); + expect(findInputInCheckbox().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('initialActivated is true', () => { + beforeEach(() => { + createComponent({ + initialActivated: true, + }); + }); + + it('renders GlFormCheckbox as checked', () => { + expect(findGlFormCheckbox().exists()).toBe(true); + expect(findGlFormCheckbox().vm.$attrs.checked).toBe(true); + }); + + describe('on checkbox click', () => { + it('switches the form value', async () => { + findInputInCheckbox().trigger('click'); + + await wrapper.vm.$nextTick(); + + expect(findGlFormCheckbox().vm.$attrs.checked).toBe(false); + }); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/active_toggle_spec.js b/spec/frontend/integrations/edit/components/active_toggle_spec.js deleted file mode 100644 index 228d8f5fc30..00000000000 --- a/spec/frontend/integrations/edit/components/active_toggle_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlToggle } from '@gitlab/ui'; - -import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; - -const GL_TOGGLE_ACTIVE_CLASS = 'is-checked'; -const GL_TOGGLE_DISABLED_CLASS = 'is-disabled'; - -describe('ActiveToggle', () => { - let wrapper; - - const defaultProps = { - initialActivated: true, - }; - - const createComponent = (props = {}, isInheriting = false) => { - wrapper = mount(ActiveToggle, { - propsData: { ...defaultProps, ...props }, - computed: { - isInheriting: () => isInheriting, - }, - }); - }; - - afterEach(() => { - if (wrapper) { - wrapper.destroy(); - wrapper = null; - } - }); - - const findGlToggle = () => wrapper.find(GlToggle); - const findButtonInToggle = () => findGlToggle().find('button'); - const findInputInToggle = () => findGlToggle().find('input'); - - describe('template', () => { - describe('is inheriting adminSettings', () => { - it('renders GlToggle as disabled', () => { - createComponent({}, true); - - expect(findGlToggle().exists()).toBe(true); - expect(findButtonInToggle().classes()).toContain(GL_TOGGLE_DISABLED_CLASS); - }); - }); - - describe('initialActivated is false', () => { - it('renders GlToggle as inactive', () => { - createComponent({ - initialActivated: false, - }); - - expect(findGlToggle().exists()).toBe(true); - expect(findButtonInToggle().classes()).not.toContain(GL_TOGGLE_ACTIVE_CLASS); - expect(findInputInToggle().attributes('value')).toBe('false'); - }); - }); - - describe('initialActivated is true', () => { - beforeEach(() => { - createComponent(); - }); - - it('renders GlToggle as active', () => { - expect(findGlToggle().exists()).toBe(true); - expect(findButtonInToggle().classes()).toContain(GL_TOGGLE_ACTIVE_CLASS); - expect(findInputInToggle().attributes('value')).toBe('true'); - }); - - describe('on toggle click', () => { - it('switches the form value', () => { - findButtonInToggle().trigger('click'); - - wrapper.vm.$nextTick(() => { - expect(findButtonInToggle().classes()).not.toContain(GL_TOGGLE_ACTIVE_CLASS); - expect(findInputInToggle().attributes('value')).toBe('false'); - }); - }); - }); - }); - }); -}); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index f8e2eb5e7f4..eeb5d21d62c 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -3,7 +3,7 @@ import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; import { createStore } from '~/integrations/edit/store'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; -import ActiveToggle from '~/integrations/edit/components/active_toggle.vue'; +import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_fields.vue'; import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; @@ -21,7 +21,7 @@ describe('IntegrationForm', () => { }), stubs: { OverrideDropdown, - ActiveToggle, + ActiveCheckbox, JiraTriggerFields, TriggerFields, }, @@ -39,27 +39,27 @@ describe('IntegrationForm', () => { }); const findOverrideDropdown = () => wrapper.find(OverrideDropdown); - const findActiveToggle = () => wrapper.find(ActiveToggle); + const findActiveCheckbox = () => wrapper.find(ActiveCheckbox); const findJiraTriggerFields = () => wrapper.find(JiraTriggerFields); const findJiraIssuesFields = () => wrapper.find(JiraIssuesFields); const findTriggerFields = () => wrapper.find(TriggerFields); describe('template', () => { describe('showActive is true', () => { - it('renders ActiveToggle', () => { + it('renders ActiveCheckbox', () => { createComponent(); - expect(findActiveToggle().exists()).toBe(true); + expect(findActiveCheckbox().exists()).toBe(true); }); }); describe('showActive is false', () => { - it('does not render ActiveToggle', () => { + it('does not render ActiveCheckbox', () => { createComponent({ showActive: false, }); - expect(findActiveToggle().exists()).toBe(false); + expect(findActiveCheckbox().exists()).toBe(false); }); }); @@ -137,13 +137,13 @@ describe('IntegrationForm', () => { }); }); - describe('adminState state is null', () => { + describe('defaultState state is null', () => { it('does not render OverrideDropdown', () => { createComponent( {}, {}, { - adminState: null, + defaultState: null, }, ); @@ -151,13 +151,13 @@ describe('IntegrationForm', () => { }); }); - describe('adminState state is an object', () => { + describe('defaultState state is an object', () => { it('renders OverrideDropdown', () => { createComponent( {}, {}, { - adminState: { + defaultState: { ...mockIntegrationProps, }, }, diff --git a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js index f58825f6297..a727bb9c734 100644 --- a/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js +++ b/spec/frontend/integrations/edit/components/jira_issues_fields_spec.js @@ -57,7 +57,7 @@ describe('JiraIssuesFields', () => { // As per https://vuejs.org/v2/guide/forms.html#Checkbox-1, // browsers don't include unchecked boxes in form submissions. it('includes issues_enabled as false even if unchecked', () => { - expect(wrapper.contains('input[name="service[issues_enabled]"]')).toBe(true); + expect(wrapper.find('input[name="service[issues_enabled]"]').exists()).toBe(true); }); it('disables project_key input', () => { @@ -90,7 +90,23 @@ describe('JiraIssuesFields', () => { it('contains link to editProjectPath', () => { createComponent(); - expect(wrapper.contains(`a[href="${defaultProps.editProjectPath}"]`)).toBe(true); + expect(wrapper.find(`a[href="${defaultProps.editProjectPath}"]`).exists()).toBe(true); + }); + + describe('GitLab issues warning', () => { + const expectedText = 'Consider disabling GitLab issues'; + + it('contains warning when GitLab issues is enabled', () => { + createComponent(); + + expect(wrapper.text()).toContain(expectedText); + }); + + it('does not contain warning when GitLab issues is disabled', () => { + createComponent({ gitlabIssuesEnabled: false }); + + expect(wrapper.text()).not.toContain(expectedText); + }); }); }); }); diff --git a/spec/frontend/integrations/edit/components/override_dropdown_spec.js b/spec/frontend/integrations/edit/components/override_dropdown_spec.js new file mode 100644 index 00000000000..f312c456d5f --- /dev/null +++ b/spec/frontend/integrations/edit/components/override_dropdown_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlLink } from '@gitlab/ui'; +import { createStore } from '~/integrations/edit/store'; + +import { integrationLevels, overrideDropdownDescriptions } from '~/integrations/edit/constants'; +import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; + +describe('OverrideDropdown', () => { + let wrapper; + + const defaultProps = { + inheritFromId: 1, + override: true, + }; + + const defaultDefaultStateProps = { + integrationLevel: 'group', + }; + + const createComponent = (props = {}, defaultStateProps = {}) => { + wrapper = shallowMount(OverrideDropdown, { + propsData: { ...defaultProps, ...props }, + store: createStore({ + defaultState: { ...defaultDefaultStateProps, ...defaultStateProps }, + }), + }); + }; + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findGlLink = () => wrapper.find(GlLink); + const findGlDropdown = () => wrapper.find(GlDropdown); + + describe('template', () => { + describe('override prop is true', () => { + it('renders GlToggle as disabled', () => { + createComponent(); + + expect(findGlDropdown().props('text')).toBe('Use custom settings'); + }); + }); + + describe('override prop is false', () => { + it('renders GlToggle as disabled', () => { + createComponent({ override: false }); + + expect(findGlDropdown().props('text')).toBe('Use default settings'); + }); + }); + + describe('integrationLevel is "project"', () => { + it('renders copy mentioning instance (as default fallback)', () => { + createComponent( + {}, + { + integrationLevel: 'project', + }, + ); + + expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.INSTANCE]); + }); + }); + + describe('integrationLevel is "group"', () => { + it('renders copy mentioning group', () => { + createComponent( + {}, + { + integrationLevel: 'group', + }, + ); + + expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.GROUP]); + }); + }); + + describe('integrationLevel is "instance"', () => { + it('renders copy mentioning instance', () => { + createComponent( + {}, + { + integrationLevel: 'instance', + }, + ); + + expect(wrapper.text()).toContain(overrideDropdownDescriptions[integrationLevels.INSTANCE]); + }); + }); + + describe('learnMorePath is present', () => { + it('renders GlLink with correct link', () => { + createComponent({ + learnMorePath: '/docs', + }); + + expect(findGlLink().text()).toBe('Learn more'); + expect(findGlLink().attributes('href')).toBe('/docs'); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/mock_data.js b/spec/frontend/integrations/edit/mock_data.js index da2758ec15c..821972b7698 100644 --- a/spec/frontend/integrations/edit/mock_data.js +++ b/spec/frontend/integrations/edit/mock_data.js @@ -1,9 +1,6 @@ -// eslint-disable-next-line import/prefer-default-export export const mockIntegrationProps = { id: 25, - activeToggleProps: { - initialActivated: true, - }, + initialActivated: true, showActive: true, triggerFieldsProps: { initialTriggerCommit: false, diff --git a/spec/frontend/integrations/edit/store/getters_spec.js b/spec/frontend/integrations/edit/store/getters_spec.js index 700d36edaad..3353e0c84cc 100644 --- a/spec/frontend/integrations/edit/store/getters_spec.js +++ b/spec/frontend/integrations/edit/store/getters_spec.js @@ -5,22 +5,22 @@ import { mockIntegrationProps } from '../mock_data'; describe('Integration form store getters', () => { let state; const customState = { ...mockIntegrationProps, type: 'CustomState' }; - const adminState = { ...mockIntegrationProps, type: 'AdminState' }; + const defaultState = { ...mockIntegrationProps, type: 'DefaultState' }; beforeEach(() => { state = createState({ customState }); }); describe('isInheriting', () => { - describe('when adminState is null', () => { + describe('when defaultState is null', () => { it('returns false', () => { expect(isInheriting(state)).toBe(false); }); }); - describe('when adminState is an object', () => { + describe('when defaultState is an object', () => { beforeEach(() => { - state.adminState = adminState; + state.defaultState = defaultState; }); describe('when override is false', () => { @@ -47,11 +47,11 @@ describe('Integration form store getters', () => { describe('propsSource', () => { beforeEach(() => { - state.adminState = adminState; + state.defaultState = defaultState; }); - it('equals adminState if inheriting', () => { - expect(propsSource(state, { isInheriting: true })).toEqual(adminState); + it('equals defaultState if inheriting', () => { + expect(propsSource(state, { isInheriting: true })).toEqual(defaultState); }); it('equals customState if not inheriting', () => { diff --git a/spec/frontend/integrations/edit/store/state_spec.js b/spec/frontend/integrations/edit/store/state_spec.js index a8b431aa310..fc193850a94 100644 --- a/spec/frontend/integrations/edit/store/state_spec.js +++ b/spec/frontend/integrations/edit/store/state_spec.js @@ -3,8 +3,10 @@ import createState from '~/integrations/edit/store/state'; describe('Integration form state factory', () => { it('states default to null', () => { expect(createState()).toEqual({ - adminState: null, + defaultState: null, customState: {}, + isSaving: false, + isTesting: false, override: false, }); }); @@ -17,9 +19,9 @@ describe('Integration form state factory', () => { [null, { inheritFromId: null }, false], [null, { inheritFromId: 25 }, false], ])( - 'for adminState: %p, customState: %p: override = `%p`', - (adminState, customState, expected) => { - expect(createState({ adminState, customState }).override).toEqual(expected); + 'for defaultState: %p, customState: %p: override = `%p`', + (defaultState, customState, expected) => { + expect(createState({ defaultState, customState }).override).toEqual(expected); }, ); }); diff --git a/spec/frontend/integrations/integration_settings_form_spec.js b/spec/frontend/integrations/integration_settings_form_spec.js index c117a37ff2f..bba851ad796 100644 --- a/spec/frontend/integrations/integration_settings_form_spec.js +++ b/spec/frontend/integrations/integration_settings_form_spec.js @@ -1,7 +1,9 @@ -import $ from 'jquery'; import MockAdaptor from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import IntegrationSettingsForm from '~/integrations/integration_settings_form'; +import toast from '~/vue_shared/plugins/global_toast'; + +jest.mock('~/vue_shared/plugins/global_toast'); describe('IntegrationSettingsForm', () => { const FIXTURE = 'services/edit_service.html'; @@ -11,7 +13,7 @@ describe('IntegrationSettingsForm', () => { loadFixtures(FIXTURE); }); - describe('contructor', () => { + describe('constructor', () => { let integrationSettingsForm; beforeEach(() => { @@ -24,16 +26,10 @@ describe('IntegrationSettingsForm', () => { expect(integrationSettingsForm.$form).toBeDefined(); expect(integrationSettingsForm.$form.prop('nodeName')).toEqual('FORM'); expect(integrationSettingsForm.formActive).toBeDefined(); - - // Form Child Elements - expect(integrationSettingsForm.$submitBtn).toBeDefined(); - expect(integrationSettingsForm.$submitBtnLoader).toBeDefined(); - expect(integrationSettingsForm.$submitBtnLabel).toBeDefined(); }); it('should initialize form metadata on class object', () => { expect(integrationSettingsForm.testEndPoint).toBeDefined(); - expect(integrationSettingsForm.canTestService).toBeDefined(); }); }); @@ -59,69 +55,6 @@ describe('IntegrationSettingsForm', () => { }); }); - describe('toggleSubmitBtnLabel', () => { - let integrationSettingsForm; - - beforeEach(() => { - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - }); - - it('should set Save button label to "Test settings and save changes" when serviceActive & canTestService are `true`', () => { - integrationSettingsForm.canTestService = true; - integrationSettingsForm.formActive = true; - - integrationSettingsForm.toggleSubmitBtnLabel(); - - expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual( - 'Test settings and save changes', - ); - }); - - it('should set Save button label to "Save changes" when either serviceActive or canTestService (or both) is `false`', () => { - integrationSettingsForm.canTestService = false; - integrationSettingsForm.formActive = false; - - integrationSettingsForm.toggleSubmitBtnLabel(); - - expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); - - integrationSettingsForm.formActive = true; - - integrationSettingsForm.toggleSubmitBtnLabel(); - - expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); - - integrationSettingsForm.canTestService = true; - integrationSettingsForm.formActive = false; - - integrationSettingsForm.toggleSubmitBtnLabel(); - - expect(integrationSettingsForm.$submitBtnLabel.text()).toEqual('Save changes'); - }); - }); - - describe('toggleSubmitBtnState', () => { - let integrationSettingsForm; - - beforeEach(() => { - integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); - }); - - it('should disable Save button and show loader animation when called with `true`', () => { - integrationSettingsForm.toggleSubmitBtnState(true); - - expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeTruthy(); - expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeFalsy(); - }); - - it('should enable Save button and hide loader animation when called with `false`', () => { - integrationSettingsForm.toggleSubmitBtnState(false); - - expect(integrationSettingsForm.$submitBtn.is(':disabled')).toBeFalsy(); - expect(integrationSettingsForm.$submitBtnLoader.hasClass('hidden')).toBeTruthy(); - }); - }); - describe('testSettings', () => { let integrationSettingsForm; let formData; @@ -133,6 +66,8 @@ describe('IntegrationSettingsForm', () => { jest.spyOn(axios, 'put'); integrationSettingsForm = new IntegrationSettingsForm('.js-integration-settings-form'); + integrationSettingsForm.init(); + // eslint-disable-next-line no-jquery/no-serialize formData = integrationSettingsForm.$form.serialize(); }); @@ -141,128 +76,60 @@ describe('IntegrationSettingsForm', () => { mock.restore(); }); - it('should make an ajax request with provided `formData`', () => { - return integrationSettingsForm.testSettings(formData).then(() => { - expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); - }); - }); - - it('should show error Flash with `Save anyway` action if ajax request responds with error in test', () => { - const errorMessage = 'Test failed.'; - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: errorMessage, - service_response: 'some error', - test_failed: true, - }); + it('should make an ajax request with provided `formData`', async () => { + await integrationSettingsForm.testSettings(formData); - return integrationSettingsForm.testSettings(formData).then(() => { - const $flashContainer = $('.flash-container'); - - expect( - $flashContainer - .find('.flash-text') - .text() - .trim(), - ).toEqual('Test failed. some error'); - - expect($flashContainer.find('.flash-action')).toBeDefined(); - expect( - $flashContainer - .find('.flash-action') - .text() - .trim(), - ).toEqual('Save anyway'); - }); + expect(axios.put).toHaveBeenCalledWith(integrationSettingsForm.testEndPoint, formData); }); - it('should not show error Flash with `Save anyway` action if ajax request responds with error in validation', () => { - const errorMessage = 'Validations failed.'; - mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { - error: true, - message: errorMessage, - service_response: 'some error', - test_failed: false, - }); - - return integrationSettingsForm.testSettings(formData).then(() => { - const $flashContainer = $('.flash-container'); - - expect( - $flashContainer - .find('.flash-text') - .text() - .trim(), - ).toEqual('Validations failed. some error'); - - expect($flashContainer.find('.flash-action')).toBeDefined(); - expect( - $flashContainer - .find('.flash-action') - .text() - .trim(), - ).toEqual(''); - }); - }); - - it('should submit form if ajax request responds without any error in test', () => { + it('should show success message if test is successful', async () => { jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { error: false, }); - return integrationSettingsForm.testSettings(formData).then(() => { - expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); - }); - }); + await integrationSettingsForm.testSettings(formData); - it('should submit form when clicked on `Save anyway` action of error Flash', () => { - jest.spyOn(integrationSettingsForm.$form, 'submit').mockImplementation(() => {}); + expect(toast).toHaveBeenCalledWith('Connection successful.'); + }); + it('should show error message if ajax request responds with test error', async () => { const errorMessage = 'Test failed.'; + const serviceResponse = 'some error'; + mock.onPut(integrationSettingsForm.testEndPoint).reply(200, { error: true, message: errorMessage, - test_failed: true, + service_response: serviceResponse, + test_failed: false, }); - return integrationSettingsForm - .testSettings(formData) - .then(() => { - const $flashAction = $('.flash-container .flash-action'); + await integrationSettingsForm.testSettings(formData); - expect($flashAction).toBeDefined(); - - $flashAction.get(0).click(); - }) - .then(() => { - expect(integrationSettingsForm.$form.submit).toHaveBeenCalled(); - }); + expect(toast).toHaveBeenCalledWith(`${errorMessage} ${serviceResponse}`); }); - it('should show error Flash if ajax request failed', () => { + it('should show error message if ajax request failed', async () => { const errorMessage = 'Something went wrong on our end.'; mock.onPut(integrationSettingsForm.testEndPoint).networkError(); - return integrationSettingsForm.testSettings(formData).then(() => { - expect( - $('.flash-container .flash-text') - .text() - .trim(), - ).toEqual(errorMessage); - }); + await integrationSettingsForm.testSettings(formData); + + expect(toast).toHaveBeenCalledWith(errorMessage); }); - it('should always call `toggleSubmitBtnState` with `false` once request is completed', () => { + it('should always dispatch `setIsTesting` with `false` once request is completed', async () => { + const dispatchSpy = jest.fn(); + mock.onPut(integrationSettingsForm.testEndPoint).networkError(); - jest.spyOn(integrationSettingsForm, 'toggleSubmitBtnState').mockImplementation(() => {}); + integrationSettingsForm.vue.$store = { dispatch: dispatchSpy }; - return integrationSettingsForm.testSettings(formData).then(() => { - expect(integrationSettingsForm.toggleSubmitBtnState).toHaveBeenCalledWith(false); - }); + await integrationSettingsForm.testSettings(formData); + + expect(dispatchSpy).toHaveBeenCalledWith('setIsTesting', false); }); }); }); diff --git a/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js new file mode 100644 index 00000000000..bfbe4ec8e70 --- /dev/null +++ b/spec/frontend/issuable/related_issues/components/add_issuable_form_spec.js @@ -0,0 +1,289 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { issuableTypesMap, linkedIssueTypesMap, PathIdSeparator } from '~/related_issues/constants'; +import AddIssuableForm from '~/related_issues/components/add_issuable_form.vue'; + +const issuable1 = { + id: 200, + reference: 'foo/bar#123', + displayReference: '#123', + title: 'some title', + path: '/foo/bar/issues/123', + state: 'opened', +}; + +const issuable2 = { + id: 201, + reference: 'foo/bar#124', + displayReference: '#124', + title: 'some other thing', + path: '/foo/bar/issues/124', + state: 'opened', +}; + +const pathIdSeparator = PathIdSeparator.Issue; + +const findFormInput = wrapper => wrapper.find('.js-add-issuable-form-input').element; + +const findRadioInput = (inputs, value) => inputs.filter(input => input.element.value === value)[0]; + +const findRadioInputs = wrapper => wrapper.findAll('[name="linked-issue-type-radio"]'); + +const constructWrapper = props => { + return shallowMount(AddIssuableForm, { + propsData: { + inputValue: '', + pendingReferences: [], + pathIdSeparator, + ...props, + }, + }); +}; + +describe('AddIssuableForm', () => { + let wrapper; + + afterEach(() => { + // Jest doesn't blur an item even if it is destroyed, + // so blur the input manually after each test + const input = findFormInput(wrapper); + if (input) input.blur(); + + wrapper.destroy(); + }); + + describe('with data', () => { + describe('without references', () => { + describe('without any input text', () => { + beforeEach(() => { + wrapper = shallowMount(AddIssuableForm, { + propsData: { + inputValue: '', + pendingReferences: [], + pathIdSeparator, + }, + }); + }); + + it('should have disabled submit button', () => { + expect(wrapper.vm.$refs.addButton.disabled).toBe(true); + expect(wrapper.vm.$refs.loadingIcon).toBeUndefined(); + }); + }); + + describe('with input text', () => { + beforeEach(() => { + wrapper = shallowMount(AddIssuableForm, { + propsData: { + inputValue: 'foo', + pendingReferences: [], + pathIdSeparator, + }, + }); + }); + + it('should not have disabled submit button', () => { + expect(wrapper.vm.$refs.addButton.disabled).toBe(false); + }); + }); + }); + + describe('with references', () => { + const inputValue = 'foo #123'; + + beforeEach(() => { + wrapper = mount(AddIssuableForm, { + propsData: { + inputValue, + pendingReferences: [issuable1.reference, issuable2.reference], + pathIdSeparator, + }, + }); + }); + + it('should put input value in place', () => { + expect(findFormInput(wrapper).value).toEqual(inputValue); + }); + + it('should render pending issuables items', () => { + expect(wrapper.findAll('.js-add-issuable-form-token-list-item').length).toEqual(2); + }); + + it('should not have disabled submit button', () => { + expect(wrapper.vm.$refs.addButton.disabled).toBe(false); + }); + }); + + describe('when issuable type is "issue"', () => { + beforeEach(() => { + wrapper = mount(AddIssuableForm, { + propsData: { + inputValue: '', + issuableType: issuableTypesMap.ISSUE, + pathIdSeparator, + pendingReferences: [], + }, + }); + }); + + it('does not show radio inputs', () => { + expect(findRadioInputs(wrapper).length).toBe(0); + }); + }); + + describe('when issuable type is "epic"', () => { + beforeEach(() => { + wrapper = shallowMount(AddIssuableForm, { + propsData: { + inputValue: '', + issuableType: issuableTypesMap.EPIC, + pathIdSeparator, + pendingReferences: [], + }, + }); + }); + + it('does not show radio inputs', () => { + expect(findRadioInputs(wrapper).length).toBe(0); + }); + }); + + describe('when it is a Linked Issues form', () => { + beforeEach(() => { + wrapper = mount(AddIssuableForm, { + propsData: { + inputValue: '', + showCategorizedIssues: true, + issuableType: issuableTypesMap.ISSUE, + pathIdSeparator, + pendingReferences: [], + }, + }); + }); + + it('shows radio inputs to allow categorisation of blocking issues', () => { + expect(findRadioInputs(wrapper).length).toBeGreaterThan(0); + }); + + describe('form radio buttons', () => { + let radioInputs; + + beforeEach(() => { + radioInputs = findRadioInputs(wrapper); + }); + + it('shows "relates to" option', () => { + expect(findRadioInput(radioInputs, linkedIssueTypesMap.RELATES_TO)).not.toBeNull(); + }); + + it('shows "blocks" option', () => { + expect(findRadioInput(radioInputs, linkedIssueTypesMap.BLOCKS)).not.toBeNull(); + }); + + it('shows "is blocked by" option', () => { + expect(findRadioInput(radioInputs, linkedIssueTypesMap.IS_BLOCKED_BY)).not.toBeNull(); + }); + + it('shows 3 options in total', () => { + expect(radioInputs.length).toBe(3); + }); + }); + + describe('when the form is submitted', () => { + it('emits an event with a "relates_to" link type when the "relates to" radio input selected', done => { + jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); + + wrapper.vm.linkedIssueType = linkedIssueTypesMap.RELATES_TO; + wrapper.vm.onFormSubmit(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', { + pendingReferences: '', + linkedIssueType: linkedIssueTypesMap.RELATES_TO, + }); + done(); + }); + }); + + it('emits an event with a "blocks" link type when the "blocks" radio input selected', done => { + jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); + + wrapper.vm.linkedIssueType = linkedIssueTypesMap.BLOCKS; + wrapper.vm.onFormSubmit(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', { + pendingReferences: '', + linkedIssueType: linkedIssueTypesMap.BLOCKS, + }); + done(); + }); + }); + + it('emits an event with a "is_blocked_by" link type when the "is blocked by" radio input selected', done => { + jest.spyOn(wrapper.vm, '$emit').mockImplementation(() => {}); + + wrapper.vm.linkedIssueType = linkedIssueTypesMap.IS_BLOCKED_BY; + wrapper.vm.onFormSubmit(); + + wrapper.vm.$nextTick(() => { + expect(wrapper.vm.$emit).toHaveBeenCalledWith('addIssuableFormSubmit', { + pendingReferences: '', + linkedIssueType: linkedIssueTypesMap.IS_BLOCKED_BY, + }); + done(); + }); + }); + + it('shows error message when error is present', done => { + const itemAddFailureMessage = 'Something went wrong while submitting.'; + wrapper.setProps({ + hasError: true, + itemAddFailureMessage, + }); + + wrapper.vm.$nextTick(() => { + expect(wrapper.find('.gl-field-error').exists()).toBe(true); + expect(wrapper.find('.gl-field-error').text()).toContain(itemAddFailureMessage); + done(); + }); + }); + }); + }); + }); + + describe('computed', () => { + describe('transformedAutocompleteSources', () => { + const autoCompleteSources = { + issues: 'http://localhost/autocomplete/issues', + epics: 'http://localhost/autocomplete/epics', + }; + + it('returns autocomplete object', () => { + wrapper = constructWrapper({ + autoCompleteSources, + }); + + expect(wrapper.vm.transformedAutocompleteSources).toBe(autoCompleteSources); + + wrapper = constructWrapper({ + autoCompleteSources, + confidential: false, + }); + + expect(wrapper.vm.transformedAutocompleteSources).toBe(autoCompleteSources); + }); + + it('returns autocomplete sources with query `confidential_only`, when it is confidential', () => { + wrapper = constructWrapper({ + autoCompleteSources, + confidential: true, + }); + + const actualSources = wrapper.vm.transformedAutocompleteSources; + + expect(actualSources.epics).toContain('?confidential_only=true'); + expect(actualSources.issues).toContain('?confidential_only=true'); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/issue_token_spec.js b/spec/frontend/issuable/related_issues/components/issue_token_spec.js new file mode 100644 index 00000000000..553721fa783 --- /dev/null +++ b/spec/frontend/issuable/related_issues/components/issue_token_spec.js @@ -0,0 +1,241 @@ +import Vue from 'vue'; +import { PathIdSeparator } from '~/related_issues/constants'; +import issueToken from '~/related_issues/components/issue_token.vue'; + +describe('IssueToken', () => { + const idKey = 200; + const displayReference = 'foo/bar#123'; + const title = 'some title'; + const pathIdSeparator = PathIdSeparator.Issue; + const eventNamespace = 'pendingIssuable'; + let IssueToken; + let vm; + + beforeEach(() => { + IssueToken = Vue.extend(issueToken); + }); + + afterEach(() => { + if (vm) { + vm.$destroy(); + } + }); + + describe('with reference supplied', () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + }, + }).$mount(); + }); + + it('shows reference', () => { + expect(vm.$el.textContent.trim()).toEqual(displayReference); + }); + + it('does not link without path specified', () => { + expect(vm.$refs.link.tagName.toLowerCase()).toEqual('span'); + expect(vm.$refs.link.getAttribute('href')).toBeNull(); + }); + }); + + describe('with reference and title supplied', () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + title, + }, + }).$mount(); + }); + + it('shows reference and title', () => { + expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference); + expect(vm.$refs.title.textContent.trim()).toEqual(title); + }); + }); + + describe('with path supplied', () => { + const path = '/foo/bar/issues/123'; + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + title, + path, + }, + }).$mount(); + }); + + it('links reference and title', () => { + expect(vm.$refs.link.getAttribute('href')).toEqual(path); + }); + }); + + describe('with state supplied', () => { + describe("`state: 'opened'`", () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + state: 'opened', + }, + }).$mount(); + }); + + it('shows green circle icon', () => { + expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined(); + }); + }); + + describe("`state: 'reopened'`", () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + state: 'reopened', + }, + }).$mount(); + }); + + it('shows green circle icon', () => { + expect(vm.$el.querySelector('.issue-token-state-icon-open.fa.fa-circle-o')).toBeDefined(); + }); + }); + + describe("`state: 'closed'`", () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + state: 'closed', + }, + }).$mount(); + }); + + it('shows red minus icon', () => { + expect(vm.$el.querySelector('.issue-token-state-icon-closed.fa.fa-minus')).toBeDefined(); + }); + }); + }); + + describe('with reference, title, state', () => { + const state = 'opened'; + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + title, + state, + }, + }).$mount(); + }); + + it('shows reference, title, and state', () => { + const stateIcon = vm.$refs.reference.querySelector('svg'); + + expect(stateIcon.getAttribute('aria-label')).toEqual(state); + expect(vm.$refs.reference.textContent.trim()).toEqual(displayReference); + expect(vm.$refs.title.textContent.trim()).toEqual(title); + }); + }); + + describe('with canRemove', () => { + describe('`canRemove: false` (default)', () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + }, + }).$mount(); + }); + + it('does not have remove button', () => { + expect(vm.$el.querySelector('.issue-token-remove-button')).toBeNull(); + }); + }); + + describe('`canRemove: true`', () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + canRemove: true, + }, + }).$mount(); + }); + + it('has remove button', () => { + expect(vm.$el.querySelector('.issue-token-remove-button')).toBeDefined(); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + }, + }).$mount(); + }); + + it('when getting checked', () => { + jest.spyOn(vm, '$emit').mockImplementation(() => {}); + vm.onRemoveRequest(); + + expect(vm.$emit).toHaveBeenCalledWith('pendingIssuableRemoveRequest', vm.idKey); + }); + }); + + describe('tooltip', () => { + beforeEach(() => { + vm = new IssueToken({ + propsData: { + idKey, + eventNamespace, + displayReference, + pathIdSeparator, + canRemove: true, + }, + }).$mount(); + }); + + it('should not be escaped', () => { + const { originalTitle } = vm.$refs.removeButton.dataset; + + expect(originalTitle).toEqual(`Remove ${displayReference}`); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js new file mode 100644 index 00000000000..0f88e4d71fe --- /dev/null +++ b/spec/frontend/issuable/related_issues/components/related_issues_block_spec.js @@ -0,0 +1,206 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlButton, GlIcon } from '@gitlab/ui'; +import { + issuable1, + issuable2, + issuable3, +} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +import RelatedIssuesBlock from '~/related_issues/components/related_issues_block.vue'; +import { + linkedIssueTypesMap, + linkedIssueTypesTextMap, + PathIdSeparator, +} from '~/related_issues/constants'; + +describe('RelatedIssuesBlock', () => { + let wrapper; + + const findIssueCountBadgeAddButton = () => wrapper.find(GlButton); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with defaults', () => { + beforeEach(() => { + wrapper = mount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'issue', + }, + }); + }); + + it('displays "Linked issues" in the header', () => { + expect(wrapper.find('.card-title').text()).toContain('Linked issues'); + }); + + it('unable to add new related issues', () => { + expect(findIssueCountBadgeAddButton().exists()).toBe(false); + }); + + it('add related issues form is hidden', () => { + expect(wrapper.find('.js-add-related-issues-form-area').exists()).toBe(false); + }); + }); + + describe('with headerText slot', () => { + it('displays header text slot data', () => { + const headerText = '<div>custom header text</div>'; + + wrapper = shallowMount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'issue', + }, + slots: { headerText }, + }); + + expect(wrapper.find('.card-title').html()).toContain(headerText); + }); + }); + + describe('with headerActions slot', () => { + it('displays header actions slot data', () => { + const headerActions = '<button data-testid="custom-button">custom button</button>'; + + wrapper = shallowMount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'issue', + }, + slots: { headerActions }, + }); + + expect(wrapper.find('[data-testid="custom-button"]').html()).toBe(headerActions); + }); + }); + + describe('with isFetching=true', () => { + beforeEach(() => { + wrapper = mount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + isFetching: true, + issuableType: 'issue', + }, + }); + }); + + it('should show `...` badge count', () => { + expect(wrapper.vm.badgeLabel).toBe('...'); + }); + }); + + describe('with canAddRelatedIssues=true', () => { + beforeEach(() => { + wrapper = mount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + canAdmin: true, + issuableType: 'issue', + }, + }); + }); + + it('can add new related issues', () => { + expect(findIssueCountBadgeAddButton().exists()).toBe(true); + }); + }); + + describe('with isFormVisible=true', () => { + beforeEach(() => { + wrapper = mount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + isFormVisible: true, + issuableType: 'issue', + }, + }); + }); + + it('shows add related issues form', () => { + expect(wrapper.find('.js-add-related-issues-form-area').exists()).toBe(true); + }); + }); + + describe('showCategorizedIssues prop', () => { + const issueList = () => wrapper.findAll('.js-related-issues-token-list-item'); + const categorizedHeadings = () => wrapper.findAll('h4'); + const headingTextAt = index => + categorizedHeadings() + .at(index) + .text(); + const mountComponent = showCategorizedIssues => { + wrapper = mount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + relatedIssues: [issuable1, issuable2, issuable3], + issuableType: 'issue', + showCategorizedIssues, + }, + }); + }; + + describe('when showCategorizedIssues=true', () => { + beforeEach(() => mountComponent(true)); + + it('should render issue tokens items', () => { + expect(issueList()).toHaveLength(3); + }); + + it('shows "Blocks" heading', () => { + const blocks = linkedIssueTypesTextMap[linkedIssueTypesMap.BLOCKS]; + + expect(headingTextAt(0)).toBe(blocks); + }); + + it('shows "Is blocked by" heading', () => { + const isBlockedBy = linkedIssueTypesTextMap[linkedIssueTypesMap.IS_BLOCKED_BY]; + + expect(headingTextAt(1)).toBe(isBlockedBy); + }); + + it('shows "Relates to" heading', () => { + const relatesTo = linkedIssueTypesTextMap[linkedIssueTypesMap.RELATES_TO]; + + expect(headingTextAt(2)).toBe(relatesTo); + }); + }); + + describe('when showCategorizedIssues=false', () => { + it('should render issues as a flat list with no header', () => { + mountComponent(false); + + expect(issueList()).toHaveLength(3); + expect(categorizedHeadings()).toHaveLength(0); + }); + }); + }); + + describe('renders correct icon when', () => { + [ + { + icon: 'issues', + issuableType: 'issue', + }, + { + icon: 'epic', + issuableType: 'epic', + }, + ].forEach(({ issuableType, icon }) => { + it(`issuableType=${issuableType} is passed`, () => { + wrapper = shallowMount(RelatedIssuesBlock, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType, + }, + }); + + const iconComponent = wrapper.find(GlIcon); + expect(iconComponent.exists()).toBe(true); + expect(iconComponent.props('name')).toBe(icon); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js new file mode 100644 index 00000000000..6cf0b9d21ea --- /dev/null +++ b/spec/frontend/issuable/related_issues/components/related_issues_list_spec.js @@ -0,0 +1,190 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { + issuable1, + issuable2, + issuable3, + issuable4, + issuable5, +} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +import IssueDueDate from '~/boards/components/issue_due_date.vue'; +import RelatedIssuesList from '~/related_issues/components/related_issues_list.vue'; +import { PathIdSeparator } from '~/related_issues/constants'; + +describe('RelatedIssuesList', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('with defaults', () => { + const heading = 'Related to'; + + beforeEach(() => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'issue', + heading, + }, + }); + }); + + it('shows a heading', () => { + expect(wrapper.find('h4').text()).toContain(heading); + }); + + it('should not show loading icon', () => { + expect(wrapper.vm.$refs.loadingIcon).toBeUndefined(); + }); + }); + + describe('with isFetching=true', () => { + beforeEach(() => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + isFetching: true, + issuableType: 'issue', + }, + }); + }); + + it('should show loading icon', () => { + expect(wrapper.vm.$refs.loadingIcon).toBeDefined(); + }); + }); + + describe('methods', () => { + beforeEach(() => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + relatedIssues: [issuable1, issuable2, issuable3, issuable4, issuable5], + issuableType: 'issue', + }, + }); + }); + + it('updates the order correctly when an item is moved to the top', () => { + const beforeAfterIds = wrapper.vm.getBeforeAfterId( + wrapper.vm.$el.querySelector('ul li:first-child'), + ); + + expect(beforeAfterIds.beforeId).toBeNull(); + expect(beforeAfterIds.afterId).toBe(2); + }); + + it('updates the order correctly when an item is moved to the bottom', () => { + const beforeAfterIds = wrapper.vm.getBeforeAfterId( + wrapper.vm.$el.querySelector('ul li:last-child'), + ); + + expect(beforeAfterIds.beforeId).toBe(4); + expect(beforeAfterIds.afterId).toBeNull(); + }); + + it('updates the order correctly when an item is swapped with adjacent item', () => { + const beforeAfterIds = wrapper.vm.getBeforeAfterId( + wrapper.vm.$el.querySelector('ul li:nth-child(3)'), + ); + + expect(beforeAfterIds.beforeId).toBe(2); + expect(beforeAfterIds.afterId).toBe(4); + }); + + it('updates the order correctly when an item is moved somewhere in the middle', () => { + const beforeAfterIds = wrapper.vm.getBeforeAfterId( + wrapper.vm.$el.querySelector('ul li:nth-child(4)'), + ); + + expect(beforeAfterIds.beforeId).toBe(3); + expect(beforeAfterIds.afterId).toBe(5); + }); + }); + + describe('issuableOrderingId returns correct issuable order id when', () => { + it('issuableType is epic', () => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'issue', + }, + }); + + expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.epicIssueId); + }); + + it('issuableType is issue', () => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'epic', + }, + }); + + expect(wrapper.vm.issuableOrderingId(issuable1)).toBe(issuable1.id); + }); + }); + + describe('renders correct ordering id when', () => { + let relatedIssues; + + beforeAll(() => { + relatedIssues = [issuable1, issuable2, issuable3, issuable4, issuable5]; + }); + + it('issuableType is epic', () => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'epic', + relatedIssues, + }, + }); + + const listItems = wrapper.vm.$el.querySelectorAll('.list-item'); + + Array.from(listItems).forEach((item, index) => { + expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].id); + }); + }); + + it('issuableType is issue', () => { + wrapper = shallowMount(RelatedIssuesList, { + propsData: { + pathIdSeparator: PathIdSeparator.Issue, + issuableType: 'issue', + relatedIssues, + }, + }); + + const listItems = wrapper.vm.$el.querySelectorAll('.list-item'); + + Array.from(listItems).forEach((item, index) => { + expect(Number(item.dataset.orderingId)).toBe(relatedIssues[index].epicIssueId); + }); + }); + }); + + describe('related item contents', () => { + beforeAll(() => { + wrapper = mount(RelatedIssuesList, { + propsData: { + issuableType: 'issue', + pathIdSeparator: PathIdSeparator.Issue, + relatedIssues: [issuable1], + }, + }); + }); + + it('shows due date', () => { + expect( + wrapper + .find(IssueDueDate) + .find('.board-card-info-text') + .text(), + ).toBe('Nov 22, 2010'); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js new file mode 100644 index 00000000000..2544d0bd030 --- /dev/null +++ b/spec/frontend/issuable/related_issues/components/related_issues_root_spec.js @@ -0,0 +1,341 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + defaultProps, + issuable1, + issuable2, +} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +import RelatedIssuesRoot from '~/related_issues/components/related_issues_root.vue'; +import relatedIssuesService from '~/related_issues/services/related_issues_service'; +import { linkedIssueTypesMap } from '~/related_issues/constants'; +import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; + +jest.mock('~/flash'); + +describe('RelatedIssuesRoot', () => { + let wrapper; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet(defaultProps.endpoint).reply(200, []); + }); + + afterEach(() => { + mock.restore(); + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const createComponent = (mountFn = mount) => { + wrapper = mountFn(RelatedIssuesRoot, { + propsData: defaultProps, + }); + + // Wait for fetch request `fetchRelatedIssues` to complete before starting to test + return waitForPromises(); + }; + + describe('methods', () => { + describe('onRelatedIssueRemoveRequest', () => { + beforeEach(() => { + jest + .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') + .mockReturnValue(Promise.reject()); + + return createComponent().then(() => { + wrapper.vm.store.setRelatedIssues([issuable1]); + }); + }); + + it('remove related issue and succeeds', () => { + mock.onDelete(issuable1.referencePath).reply(200, { issues: [] }); + + wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id); + + return axios.waitForAll().then(() => { + expect(wrapper.vm.state.relatedIssues).toEqual([]); + }); + }); + + it('remove related issue, fails, and restores to related issues', () => { + mock.onDelete(issuable1.referencePath).reply(422, {}); + + wrapper.vm.onRelatedIssueRemoveRequest(issuable1.id); + + return axios.waitForAll().then(() => { + expect(wrapper.vm.state.relatedIssues).toHaveLength(1); + expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); + }); + }); + }); + + describe('onToggleAddRelatedIssuesForm', () => { + beforeEach(() => createComponent(shallowMount)); + + it('toggle related issues form to visible', () => { + wrapper.vm.onToggleAddRelatedIssuesForm(); + + expect(wrapper.vm.isFormVisible).toEqual(true); + }); + + it('show add related issues form to hidden', () => { + wrapper.vm.isFormVisible = true; + + wrapper.vm.onToggleAddRelatedIssuesForm(); + + expect(wrapper.vm.isFormVisible).toEqual(false); + }); + }); + + describe('onPendingIssueRemoveRequest', () => { + beforeEach(() => + createComponent().then(() => { + wrapper.vm.store.setPendingReferences([issuable1.reference]); + }), + ); + + it('remove pending related issue', () => { + expect(wrapper.vm.state.pendingReferences).toHaveLength(1); + + wrapper.vm.onPendingIssueRemoveRequest(0); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + }); + }); + + describe('onPendingFormSubmit', () => { + beforeEach(() => { + jest + .spyOn(relatedIssuesService.prototype, 'fetchRelatedIssues') + .mockReturnValue(Promise.reject()); + + return createComponent().then(() => { + jest.spyOn(wrapper.vm, 'processAllReferences'); + jest.spyOn(wrapper.vm.service, 'addRelatedIssues'); + createFlash.mockClear(); + }); + }); + + it('processes references before submitting', () => { + const input = '#123'; + const linkedIssueType = linkedIssueTypesMap.RELATES_TO; + const emitObj = { + pendingReferences: input, + linkedIssueType, + }; + + wrapper.vm.onPendingFormSubmit(emitObj); + + expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); + expect(wrapper.vm.service.addRelatedIssues).toHaveBeenCalledWith([input], linkedIssueType); + }); + + it('submit zero pending issue as related issue', () => { + wrapper.vm.store.setPendingReferences([]); + wrapper.vm.onPendingFormSubmit({}); + + return waitForPromises().then(() => { + expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + expect(wrapper.vm.state.relatedIssues).toHaveLength(0); + }); + }); + + it('submit pending issue as related issue', () => { + mock.onPost(defaultProps.endpoint).reply(200, { + issuables: [issuable1], + result: { + message: 'something was successfully related', + status: 'success', + }, + }); + + wrapper.vm.store.setPendingReferences([issuable1.reference]); + wrapper.vm.onPendingFormSubmit({}); + + return waitForPromises().then(() => { + expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + expect(wrapper.vm.state.relatedIssues).toHaveLength(1); + expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); + }); + }); + + it('submit multiple pending issues as related issues', () => { + mock.onPost(defaultProps.endpoint).reply(200, { + issuables: [issuable1, issuable2], + result: { + message: 'something was successfully related', + status: 'success', + }, + }); + + wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); + wrapper.vm.onPendingFormSubmit({}); + + return waitForPromises().then(() => { + expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + expect(wrapper.vm.state.relatedIssues).toHaveLength(2); + expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); + expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id); + }); + }); + + it('displays a message from the backend upon error', () => { + const input = '#123'; + const message = 'error'; + + mock.onPost(defaultProps.endpoint).reply(409, { message }); + wrapper.vm.store.setPendingReferences([issuable1.reference, issuable2.reference]); + + expect(createFlash).not.toHaveBeenCalled(); + wrapper.vm.onPendingFormSubmit(input); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith(message); + }); + }); + }); + + describe('onPendingFormCancel', () => { + beforeEach(() => + createComponent().then(() => { + wrapper.vm.isFormVisible = true; + wrapper.vm.inputValue = 'foo'; + }), + ); + + it('when canceling and hiding add issuable form', () => { + wrapper.vm.onPendingFormCancel(); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.isFormVisible).toEqual(false); + expect(wrapper.vm.inputValue).toEqual(''); + expect(wrapper.vm.state.pendingReferences).toHaveLength(0); + }); + }); + }); + + describe('fetchRelatedIssues', () => { + beforeEach(() => createComponent()); + + it('sets isFetching while fetching', () => { + wrapper.vm.fetchRelatedIssues(); + + expect(wrapper.vm.isFetching).toEqual(true); + + return waitForPromises().then(() => { + expect(wrapper.vm.isFetching).toEqual(false); + }); + }); + + it('should fetch related issues', () => { + mock.onGet(defaultProps.endpoint).reply(200, [issuable1, issuable2]); + + wrapper.vm.fetchRelatedIssues(); + + return waitForPromises().then(() => { + expect(wrapper.vm.state.relatedIssues).toHaveLength(2); + expect(wrapper.vm.state.relatedIssues[0].id).toEqual(issuable1.id); + expect(wrapper.vm.state.relatedIssues[1].id).toEqual(issuable2.id); + }); + }); + }); + + describe('onInput', () => { + beforeEach(() => createComponent()); + + it('fill in issue number reference and adds to pending related issues', () => { + const input = '#123 '; + wrapper.vm.onInput({ + untouchedRawReferences: [input.trim()], + touchedReference: input, + }); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(1); + expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123'); + }); + + it('fill in with full reference', () => { + const input = 'asdf/qwer#444 '; + wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input }); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(1); + expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); + }); + + it('fill in with issue link', () => { + const link = 'http://localhost:3000/foo/bar/issues/111'; + const input = `${link} `; + wrapper.vm.onInput({ untouchedRawReferences: [input.trim()], touchedReference: input }); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(1); + expect(wrapper.vm.state.pendingReferences[0]).toEqual(link); + }); + + it('fill in with multiple references', () => { + const input = 'asdf/qwer#444 #12 '; + wrapper.vm.onInput({ + untouchedRawReferences: input.trim().split(/\s/), + touchedReference: 2, + }); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(2); + expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf/qwer#444'); + expect(wrapper.vm.state.pendingReferences[1]).toEqual('#12'); + }); + + it('fill in with some invalid things', () => { + const input = 'something random '; + wrapper.vm.onInput({ + untouchedRawReferences: input.trim().split(/\s/), + touchedReference: 2, + }); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(2); + expect(wrapper.vm.state.pendingReferences[0]).toEqual('something'); + expect(wrapper.vm.state.pendingReferences[1]).toEqual('random'); + }); + }); + + describe('onBlur', () => { + beforeEach(() => + createComponent().then(() => { + jest.spyOn(wrapper.vm, 'processAllReferences').mockImplementation(() => {}); + }), + ); + + it('add any references to pending when blurring', () => { + const input = '#123'; + + wrapper.vm.onBlur(input); + + expect(wrapper.vm.processAllReferences).toHaveBeenCalledWith(input); + }); + }); + + describe('processAllReferences', () => { + beforeEach(() => createComponent()); + + it('add valid reference to pending', () => { + const input = '#123'; + wrapper.vm.processAllReferences(input); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(1); + expect(wrapper.vm.state.pendingReferences[0]).toEqual('#123'); + }); + + it('add any valid references to pending', () => { + const input = 'asdf #123'; + wrapper.vm.processAllReferences(input); + + expect(wrapper.vm.state.pendingReferences).toHaveLength(2); + expect(wrapper.vm.state.pendingReferences[0]).toEqual('asdf'); + expect(wrapper.vm.state.pendingReferences[1]).toEqual('#123'); + }); + }); + }); +}); diff --git a/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js new file mode 100644 index 00000000000..ada1c44560f --- /dev/null +++ b/spec/frontend/issuable/related_issues/stores/related_issues_store_spec.js @@ -0,0 +1,111 @@ +import { + issuable1, + issuable2, + issuable3, + issuable4, + issuable5, +} from 'jest/vue_shared/components/issue/related_issuable_mock_data'; +import RelatedIssuesStore from '~/related_issues/stores/related_issues_store'; + +describe('RelatedIssuesStore', () => { + let store; + + beforeEach(() => { + store = new RelatedIssuesStore(); + }); + + describe('setRelatedIssues', () => { + it('defaults to empty array', () => { + expect(store.state.relatedIssues).toEqual([]); + }); + + it('sets issues', () => { + const relatedIssues = [issuable1]; + store.setRelatedIssues(relatedIssues); + + expect(store.state.relatedIssues).toEqual(relatedIssues); + }); + }); + + describe('addRelatedIssues', () => { + it('adds related issues', () => { + store.state.relatedIssues = [issuable1]; + store.addRelatedIssues([issuable2, issuable3]); + + expect(store.state.relatedIssues).toEqual([issuable1, issuable2, issuable3]); + }); + }); + + describe('removeRelatedIssue', () => { + it('removes issue', () => { + store.state.relatedIssues = [issuable1]; + + store.removeRelatedIssue(issuable1); + + expect(store.state.relatedIssues).toEqual([]); + }); + + it('removes issue with multiple in store', () => { + store.state.relatedIssues = [issuable1, issuable2]; + + store.removeRelatedIssue(issuable1); + + expect(store.state.relatedIssues).toEqual([issuable2]); + }); + }); + + describe('updateIssueOrder', () => { + it('updates issue order', () => { + store.state.relatedIssues = [issuable1, issuable2, issuable3, issuable4, issuable5]; + + expect(store.state.relatedIssues[3].id).toBe(issuable4.id); + store.updateIssueOrder(3, 0); + + expect(store.state.relatedIssues[0].id).toBe(issuable4.id); + }); + }); + + describe('setPendingReferences', () => { + it('defaults to empty array', () => { + expect(store.state.pendingReferences).toEqual([]); + }); + + it('sets pending references', () => { + const relatedIssues = [issuable1.reference]; + store.setPendingReferences(relatedIssues); + + expect(store.state.pendingReferences).toEqual(relatedIssues); + }); + }); + + describe('addPendingReferences', () => { + it('adds a reference', () => { + store.state.pendingReferences = [issuable1.reference]; + store.addPendingReferences([issuable2.reference, issuable3.reference]); + + expect(store.state.pendingReferences).toEqual([ + issuable1.reference, + issuable2.reference, + issuable3.reference, + ]); + }); + }); + + describe('removePendingRelatedIssue', () => { + it('removes issue', () => { + store.state.pendingReferences = [issuable1.reference]; + + store.removePendingRelatedIssue(0); + + expect(store.state.pendingReferences).toEqual([]); + }); + + it('removes issue with multiple in store', () => { + store.state.pendingReferences = [issuable1.reference, issuable2.reference]; + + store.removePendingRelatedIssue(0); + + expect(store.state.pendingReferences).toEqual([issuable2.reference]); + }); + }); +}); diff --git a/spec/frontend/issuable_create/components/issuable_create_root_spec.js b/spec/frontend/issuable_create/components/issuable_create_root_spec.js new file mode 100644 index 00000000000..675d01ae4af --- /dev/null +++ b/spec/frontend/issuable_create/components/issuable_create_root_spec.js @@ -0,0 +1,64 @@ +import { mount } from '@vue/test-utils'; + +import IssuableCreateRoot from '~/issuable_create/components/issuable_create_root.vue'; +import IssuableForm from '~/issuable_create/components/issuable_form.vue'; + +const createComponent = ({ + descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown', + descriptionHelpPath = '/help/user/markdown', + labelsFetchPath = '/gitlab-org/gitlab-shell/-/labels.json', + labelsManagePath = '/gitlab-org/gitlab-shell/-/labels', +} = {}) => { + return mount(IssuableCreateRoot, { + propsData: { + descriptionPreviewPath, + descriptionHelpPath, + labelsFetchPath, + labelsManagePath, + }, + slots: { + title: ` + <h1 class="js-create-title">New Issuable</h1> + `, + actions: ` + <button class="js-issuable-save">Submit issuable</button> + `, + }, + }); +}; + +describe('IssuableCreateRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders component container element with class "issuable-create-container"', () => { + expect(wrapper.classes()).toContain('issuable-create-container'); + }); + + it('renders contents for slot "title"', () => { + const titleEl = wrapper.find('h1.js-create-title'); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.text()).toBe('New Issuable'); + }); + + it('renders issuable-form component', () => { + expect(wrapper.find(IssuableForm).exists()).toBe(true); + }); + + it('renders contents for slot "actions" within issuable-form component', () => { + const buttonEl = wrapper.find(IssuableForm).find('button.js-issuable-save'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.text()).toBe('Submit issuable'); + }); + }); +}); diff --git a/spec/frontend/issuable_create/components/issuable_form_spec.js b/spec/frontend/issuable_create/components/issuable_form_spec.js new file mode 100644 index 00000000000..e2c6b4d9521 --- /dev/null +++ b/spec/frontend/issuable_create/components/issuable_form_spec.js @@ -0,0 +1,119 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; + +import MarkdownField from '~/vue_shared/components/markdown/field.vue'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; + +import IssuableForm from '~/issuable_create/components/issuable_form.vue'; + +const createComponent = ({ + descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown', + descriptionHelpPath = '/help/user/markdown', + labelsFetchPath = '/gitlab-org/gitlab-shell/-/labels.json', + labelsManagePath = '/gitlab-org/gitlab-shell/-/labels', +} = {}) => { + return shallowMount(IssuableForm, { + propsData: { + descriptionPreviewPath, + descriptionHelpPath, + labelsFetchPath, + labelsManagePath, + }, + slots: { + actions: ` + <button class="js-issuable-save">Submit issuable</button> + `, + }, + }); +}; + +describe('IssuableForm', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('handleUpdateSelectedLabels', () => { + it('sets provided `labels` param to prop `selectedLabels`', () => { + const labels = [ + { + id: 1, + color: '#BADA55', + text_color: '#ffffff', + title: 'Documentation', + }, + ]; + + wrapper.vm.handleUpdateSelectedLabels(labels); + + expect(wrapper.vm.selectedLabels).toBe(labels); + }); + }); + }); + + describe('template', () => { + it('renders issuable title input field', () => { + const titleFieldEl = wrapper.find('[data-testid="issuable-title"]'); + + expect(titleFieldEl.exists()).toBe(true); + expect(titleFieldEl.find('label').text()).toBe('Title'); + expect(titleFieldEl.find(GlFormInput).exists()).toBe(true); + expect(titleFieldEl.find(GlFormInput).attributes('placeholder')).toBe('Title'); + expect(titleFieldEl.find(GlFormInput).attributes('autofocus')).toBe('true'); + }); + + it('renders issuable description input field', () => { + const descriptionFieldEl = wrapper.find('[data-testid="issuable-description"]'); + + expect(descriptionFieldEl.exists()).toBe(true); + expect(descriptionFieldEl.find('label').text()).toBe('Description'); + expect(descriptionFieldEl.find(MarkdownField).exists()).toBe(true); + expect(descriptionFieldEl.find(MarkdownField).props()).toMatchObject({ + markdownPreviewPath: wrapper.vm.descriptionPreviewPath, + markdownDocsPath: wrapper.vm.descriptionHelpPath, + addSpacingClasses: false, + showSuggestPopover: true, + }); + expect(descriptionFieldEl.find('textarea').exists()).toBe(true); + expect(descriptionFieldEl.find('textarea').attributes('placeholder')).toBe( + 'Write a comment or drag your files here…', + ); + }); + + it('renders labels select field', () => { + const labelsSelectEl = wrapper.find('[data-testid="issuable-labels"]'); + + expect(labelsSelectEl.exists()).toBe(true); + expect(labelsSelectEl.find('label').text()).toBe('Labels'); + expect(labelsSelectEl.find(LabelsSelect).exists()).toBe(true); + expect(labelsSelectEl.find(LabelsSelect).props()).toMatchObject({ + allowLabelEdit: true, + allowLabelCreate: true, + allowMultiselect: true, + allowScopedLabels: true, + labelsFetchPath: wrapper.vm.labelsFetchPath, + labelsManagePath: wrapper.vm.labelsManagePath, + selectedLabels: wrapper.vm.selectedLabels, + labelsListTitle: 'Select label', + footerCreateLabelTitle: 'Create project label', + footerManageLabelTitle: 'Manage project labels', + variant: 'embedded', + }); + }); + + it('renders contents for slot "actions"', () => { + const buttonEl = wrapper + .find('[data-testid="issuable-create-actions"]') + .find('button.js-issuable-save'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.text()).toBe('Submit issuable'); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_item_spec.js b/spec/frontend/issuable_list/components/issuable_item_spec.js new file mode 100644 index 00000000000..a96a4e15e6c --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_item_spec.js @@ -0,0 +1,185 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlLabel } from '@gitlab/ui'; + +import IssuableItem from '~/issuable_list/components/issuable_item.vue'; + +import { mockIssuable, mockRegularLabel, mockScopedLabel } from '../mock_data'; + +const createComponent = ({ issuableSymbol = '#', issuable = mockIssuable } = {}) => + shallowMount(IssuableItem, { + propsData: { + issuableSymbol, + issuable, + }, + }); + +describe('IssuableItem', () => { + const mockLabels = mockIssuable.labels.nodes; + const mockAuthor = mockIssuable.author; + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('computed', () => { + describe('author', () => { + it('returns `issuable.author` reference', () => { + expect(wrapper.vm.author).toEqual(mockIssuable.author); + }); + }); + + describe('authorId', () => { + it.each` + authorId | returnValue + ${1} | ${1} + ${'1'} | ${1} + ${'gid://gitlab/User/1'} | ${'1'} + ${'foo'} | ${''} + `( + 'returns $returnValue when value of `issuable.author.id` is $authorId', + async ({ authorId, returnValue }) => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + author: { + ...mockAuthor, + id: authorId, + }, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.authorId).toBe(returnValue); + }, + ); + }); + + describe('labels', () => { + it('returns `issuable.labels.nodes` reference when it is available', () => { + expect(wrapper.vm.labels).toEqual(mockLabels); + }); + + it('returns `issuable.labels` reference when it is available', async () => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + labels: mockLabels, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.labels).toEqual(mockLabels); + }); + + it('returns empty array when none of `issuable.labels.nodes` or `issuable.labels` are available', async () => { + wrapper.setProps({ + issuable: { + ...mockIssuable, + labels: null, + }, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.labels).toEqual([]); + }); + }); + + describe('createdAt', () => { + it('returns string containing timeago string based on `issuable.createdAt`', () => { + expect(wrapper.vm.createdAt).toContain('created'); + expect(wrapper.vm.createdAt).toContain('ago'); + }); + }); + + describe('updatedAt', () => { + it('returns string containing timeago string based on `issuable.updatedAt`', () => { + expect(wrapper.vm.updatedAt).toContain('updated'); + expect(wrapper.vm.updatedAt).toContain('ago'); + }); + }); + }); + + describe('methods', () => { + describe('scopedLabel', () => { + it.each` + label | labelType | returnValue + ${mockRegularLabel} | ${'regular'} | ${false} + ${mockScopedLabel} | ${'scoped'} | ${true} + `( + 'return $returnValue when provided label param is a $labelType label', + ({ label, returnValue }) => { + expect(wrapper.vm.scopedLabel(label)).toBe(returnValue); + }, + ); + }); + }); + + describe('template', () => { + it('renders issuable title', () => { + const titleEl = wrapper.find('[data-testid="issuable-title"]'); + + expect(titleEl.exists()).toBe(true); + expect(titleEl.find(GlLink).attributes('href')).toBe(mockIssuable.webUrl); + expect(titleEl.find(GlLink).text()).toBe(mockIssuable.title); + }); + + it('renders issuable reference', () => { + const referenceEl = wrapper.find('[data-testid="issuable-reference"]'); + + expect(referenceEl.exists()).toBe(true); + expect(referenceEl.text()).toBe(`#${mockIssuable.iid}`); + }); + + it('renders issuable createdAt info', () => { + const createdAtEl = wrapper.find('[data-testid="issuable-created-at"]'); + + expect(createdAtEl.exists()).toBe(true); + expect(createdAtEl.attributes('title')).toBe('Jun 29, 2020 1:52pm GMT+0000'); + expect(createdAtEl.text()).toBe(wrapper.vm.createdAt); + }); + + it('renders issuable author info', () => { + const authorEl = wrapper.find('[data-testid="issuable-author"]'); + + expect(authorEl.exists()).toBe(true); + expect(authorEl.attributes()).toMatchObject({ + 'data-user-id': wrapper.vm.authorId, + 'data-username': mockAuthor.username, + 'data-name': mockAuthor.name, + 'data-avatar-url': mockAuthor.avatarUrl, + href: mockAuthor.webUrl, + }); + expect(authorEl.text()).toBe(mockAuthor.name); + }); + + it('renders gl-label component for each label present within `issuable` prop', () => { + const labelsEl = wrapper.findAll(GlLabel); + + expect(labelsEl.exists()).toBe(true); + expect(labelsEl).toHaveLength(mockLabels.length); + expect(labelsEl.at(0).props()).toMatchObject({ + backgroundColor: mockLabels[0].color, + title: mockLabels[0].title, + description: mockLabels[0].description, + scoped: false, + size: 'sm', + }); + }); + + it('renders issuable updatedAt info', () => { + const updatedAtEl = wrapper.find('[data-testid="issuable-updated-at"]'); + + expect(updatedAtEl.exists()).toBe(true); + expect(updatedAtEl.find('span').attributes('title')).toBe('Sep 10, 2020 11:41am GMT+0000'); + expect(updatedAtEl.text()).toBe(wrapper.vm.updatedAt); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_list_root_spec.js b/spec/frontend/issuable_list/components/issuable_list_root_spec.js new file mode 100644 index 00000000000..34184522b55 --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_list_root_spec.js @@ -0,0 +1,160 @@ +import { mount } from '@vue/test-utils'; +import { GlLoadingIcon, GlPagination } from '@gitlab/ui'; + +import IssuableListRoot from '~/issuable_list/components/issuable_list_root.vue'; +import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; +import IssuableItem from '~/issuable_list/components/issuable_item.vue'; +import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; + +import { mockIssuableListProps } from '../mock_data'; + +const createComponent = (propsData = mockIssuableListProps) => + mount(IssuableListRoot, { + propsData, + slots: { + 'nav-actions': ` + <button class="js-new-issuable">New issuable</button> + `, + 'empty-state': ` + <p class="js-issuable-empty-state">Issuable empty state</p> + `, + }, + }); + +describe('IssuableListRoot', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + it('renders component container element with class "issuable-list-container"', () => { + expect(wrapper.classes()).toContain('issuable-list-container'); + }); + + it('renders issuable-tabs component', () => { + const tabsEl = wrapper.find(IssuableTabs); + + expect(tabsEl.exists()).toBe(true); + expect(tabsEl.props()).toMatchObject({ + tabs: wrapper.vm.tabs, + tabCounts: wrapper.vm.tabCounts, + currentTab: wrapper.vm.currentTab, + }); + }); + + it('renders contents for slot "nav-actions" within issuable-tab component', () => { + const buttonEl = wrapper.find(IssuableTabs).find('button.js-new-issuable'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.text()).toBe('New issuable'); + }); + + it('renders filtered-search-bar component', () => { + const searchEl = wrapper.find(FilteredSearchBar); + const { + namespace, + recentSearchesStorageKey, + searchInputPlaceholder, + searchTokens, + sortOptions, + initialFilterValue, + initialSortBy, + } = wrapper.vm; + + expect(searchEl.exists()).toBe(true); + expect(searchEl.props()).toMatchObject({ + namespace, + recentSearchesStorageKey, + searchInputPlaceholder, + tokens: searchTokens, + sortOptions, + initialFilterValue, + initialSortBy, + }); + }); + + it('renders gl-loading-icon when `issuablesLoading` prop is true', async () => { + wrapper.setProps({ + issuablesLoading: true, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('renders issuable-item component for each item within `issuables` array', () => { + const itemsEl = wrapper.findAll(IssuableItem); + const mockIssuable = mockIssuableListProps.issuables[0]; + + expect(itemsEl).toHaveLength(mockIssuableListProps.issuables.length); + expect(itemsEl.at(0).props()).toMatchObject({ + issuableSymbol: wrapper.vm.issuableSymbol, + issuable: mockIssuable, + }); + }); + + it('renders contents for slot "empty-state" when `issuablesLoading` is false and `issuables` is empty', async () => { + wrapper.setProps({ + issuables: [], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find('p.js-issuable-empty-state').exists()).toBe(true); + expect(wrapper.find('p.js-issuable-empty-state').text()).toBe('Issuable empty state'); + }); + + it('renders gl-pagination when `showPaginationControls` prop is true', async () => { + wrapper.setProps({ + showPaginationControls: true, + }); + + await wrapper.vm.$nextTick(); + + const paginationEl = wrapper.find(GlPagination); + expect(paginationEl.exists()).toBe(true); + expect(paginationEl.props()).toMatchObject({ + perPage: 20, + value: 1, + prevPage: 0, + nextPage: 2, + align: 'center', + }); + }); + }); + + describe('events', () => { + it('issuable-tabs component emits `click-tab` event on `click-tab` event', () => { + wrapper.find(IssuableTabs).vm.$emit('click'); + + expect(wrapper.emitted('click-tab')).toBeTruthy(); + }); + + it('filtered-search-bar component emits `filter` event on `onFilter` & `sort` event on `onSort` events', () => { + const searchEl = wrapper.find(FilteredSearchBar); + + searchEl.vm.$emit('onFilter'); + expect(wrapper.emitted('filter')).toBeTruthy(); + searchEl.vm.$emit('onSort'); + expect(wrapper.emitted('sort')).toBeTruthy(); + }); + + it('gl-pagination component emits `page-change` event on `input` event', async () => { + wrapper.setProps({ + showPaginationControls: true, + }); + + await wrapper.vm.$nextTick(); + + wrapper.find(GlPagination).vm.$emit('input'); + expect(wrapper.emitted('page-change')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/issuable_list/components/issuable_tabs_spec.js b/spec/frontend/issuable_list/components/issuable_tabs_spec.js new file mode 100644 index 00000000000..12611400084 --- /dev/null +++ b/spec/frontend/issuable_list/components/issuable_tabs_spec.js @@ -0,0 +1,91 @@ +import { mount } from '@vue/test-utils'; +import { GlTab, GlBadge } from '@gitlab/ui'; + +import IssuableTabs from '~/issuable_list/components/issuable_tabs.vue'; + +import { mockIssuableListProps } from '../mock_data'; + +const createComponent = ({ + tabs = mockIssuableListProps.tabs, + tabCounts = mockIssuableListProps.tabCounts, + currentTab = mockIssuableListProps.currentTab, +} = {}) => + mount(IssuableTabs, { + propsData: { + tabs, + tabCounts, + currentTab, + }, + slots: { + 'nav-actions': ` + <button class="js-new-issuable">New issuable</button> + `, + }, + }); + +describe('IssuableTabs', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('methods', () => { + describe('isTabActive', () => { + it.each` + tabName | currentTab | returnValue + ${'opened'} | ${'opened'} | ${true} + ${'opened'} | ${'closed'} | ${false} + `( + 'returns $returnValue when tab name is "$tabName" is current tab is "$currentTab"', + async ({ tabName, currentTab, returnValue }) => { + wrapper.setProps({ + currentTab, + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.isTabActive(tabName)).toBe(returnValue); + }, + ); + }); + }); + + describe('template', () => { + it('renders gl-tab for each tab within `tabs` array', () => { + const tabsEl = wrapper.findAll(GlTab); + + expect(tabsEl.exists()).toBe(true); + expect(tabsEl).toHaveLength(mockIssuableListProps.tabs.length); + }); + + it('renders gl-badge component within a tab', () => { + const badgeEl = wrapper.findAll(GlBadge).at(0); + + expect(badgeEl.exists()).toBe(true); + expect(badgeEl.text()).toBe(`${mockIssuableListProps.tabCounts.opened}`); + }); + + it('renders contents for slot "nav-actions"', () => { + const buttonEl = wrapper.find('button.js-new-issuable'); + + expect(buttonEl.exists()).toBe(true); + expect(buttonEl.text()).toBe('New issuable'); + }); + }); + + describe('events', () => { + it('gl-tab component emits `click` event on `click` event', () => { + const tabEl = wrapper.findAll(GlTab).at(0); + + tabEl.vm.$emit('click', 'opened'); + + expect(wrapper.emitted('click')).toBeTruthy(); + expect(wrapper.emitted('click')[0]).toEqual(['opened']); + }); + }); +}); diff --git a/spec/frontend/issuable_list/mock_data.js b/spec/frontend/issuable_list/mock_data.js new file mode 100644 index 00000000000..f6f914a595d --- /dev/null +++ b/spec/frontend/issuable_list/mock_data.js @@ -0,0 +1,135 @@ +import { + mockAuthorToken, + mockLabelToken, + mockSortOptions, +} from 'jest/vue_shared/components/filtered_search_bar/mock_data'; + +export const mockAuthor = { + id: 'gid://gitlab/User/1', + avatarUrl: 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://0.0.0.0:3000/root', +}; + +export const mockRegularLabel = { + id: 'gid://gitlab/GroupLabel/2048', + title: 'Documentation Update', + description: null, + color: '#F0AD4E', + textColor: '#FFFFFF', +}; + +export const mockScopedLabel = { + id: 'gid://gitlab/ProjectLabel/2049', + title: 'status::confirmed', + description: null, + color: '#D9534F', + textColor: '#FFFFFF', +}; + +export const mockLabels = [mockRegularLabel, mockScopedLabel]; + +export const mockIssuable = { + iid: '30', + title: 'Dismiss Cipher with no integrity', + description: null, + createdAt: '2020-06-29T13:52:56Z', + updatedAt: '2020-09-10T11:41:13Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/30', + author: mockAuthor, + labels: { + nodes: mockLabels, + }, +}; + +export const mockIssuables = [ + mockIssuable, + { + iid: '28', + title: 'Dismiss Cipher with no integrity', + description: null, + createdAt: '2020-06-29T13:52:56Z', + updatedAt: '2020-06-29T13:52:56Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/28', + author: mockAuthor, + labels: { + nodes: [], + }, + }, + { + iid: '7', + title: 'Temporibus in veritatis labore explicabo velit molestiae sed.', + description: 'Quo consequatur rem aliquid laborum quibusdam molestiae saepe.', + createdAt: '2020-06-25T13:50:14Z', + updatedAt: '2020-08-25T06:09:27Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/7', + author: mockAuthor, + labels: { + nodes: mockLabels, + }, + }, + { + iid: '17', + title: 'Vel voluptatem quaerat est hic incidunt qui ut aliquid sit exercitationem.', + description: 'Incidunt accusamus perspiciatis aut excepturi.', + createdAt: '2020-06-19T13:51:36Z', + updatedAt: '2020-08-11T13:36:35Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/17', + author: mockAuthor, + labels: { + nodes: [], + }, + }, + { + iid: '16', + title: 'Vero qui quo labore libero omnis quisquam et cumque.', + description: 'Ipsa ipsum magni nostrum alias aut exercitationem.', + createdAt: '2020-06-19T13:51:36Z', + updatedAt: '2020-06-19T13:51:36Z', + webUrl: 'http://0.0.0.0:3000/gitlab-org/gitlab-shell/-/issues/16', + author: mockAuthor, + labels: { + nodes: [], + }, + }, +]; + +export const mockTabs = [ + { + id: 'state-opened', + name: 'opened', + title: 'Open', + titleTooltip: 'Filter by issuables that are currently opened.', + }, + { + id: 'state-archived', + name: 'closed', + title: 'Closed', + titleTooltip: 'Filter by issuables that are currently archived.', + }, + { + id: 'state-all', + name: 'all', + title: 'All', + titleTooltip: 'Show all issuables.', + }, +]; + +export const mockTabCounts = { + opened: 5, + closed: 0, + all: 5, +}; + +export const mockIssuableListProps = { + namespace: 'gitlab-org/gitlab-test', + recentSearchesStorageKey: 'issues', + searchInputPlaceholder: 'Search issues', + searchTokens: [mockAuthorToken, mockLabelToken], + sortOptions: mockSortOptions, + issuables: mockIssuables, + tabs: mockTabs, + tabCounts: mockTabCounts, + currentTab: 'opened', +}; diff --git a/spec/frontend/issuable_suggestions/components/app_spec.js b/spec/frontend/issuable_suggestions/components/app_spec.js index d51c89807be..0cb5b9c90ba 100644 --- a/spec/frontend/issuable_suggestions/components/app_spec.js +++ b/spec/frontend/issuable_suggestions/components/app_spec.js @@ -41,7 +41,7 @@ describe('Issuable suggestions app component', () => { wrapper.setData(data); return wrapper.vm.$nextTick(() => { - expect(wrapper.isEmpty()).toBe(false); + expect(wrapper.findAll('li').length).toBe(data.issues.length); }); }); @@ -89,8 +89,8 @@ describe('Issuable suggestions app component', () => { wrapper .findAll('li') .at(0) - .is('.gl-mb-3'), - ).toBe(true); + .classes(), + ).toContain('gl-mb-3'); }); }); @@ -102,8 +102,8 @@ describe('Issuable suggestions app component', () => { wrapper .findAll('li') .at(1) - .is('.gl-mb-3'), - ).toBe(false); + .classes(), + ).not.toContain('gl-mb-3'); }); }); }); diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js index ad37ccd2ca5..9912e77d5fe 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issuable_suggestions/components/item_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { GlTooltip, GlLink } from '@gitlab/ui'; +import { GlTooltip, GlLink, GlIcon } from '@gitlab/ui'; import { TEST_HOST } from 'jest/helpers/test_constants'; -import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarImage from '~/vue_shared/components/user_avatar/user_avatar_image.vue'; import Suggestion from '~/issuable_suggestions/components/item.vue'; import mockData from '../mock_data'; @@ -48,7 +47,7 @@ describe('Issuable suggestions suggestion component', () => { it('renders icon', () => { createComponent(); - const icon = vm.find(Icon); + const icon = vm.find(GlIcon); expect(icon.props('name')).toBe('issue-open-m'); }); @@ -71,7 +70,7 @@ describe('Issuable suggestions suggestion component', () => { state: 'closed', }); - const icon = vm.find(Icon); + const icon = vm.find(GlIcon); expect(icon.props('name')).toBe('issue-close'); }); @@ -112,7 +111,7 @@ describe('Issuable suggestions suggestion component', () => { const count = vm.findAll('.suggestion-counts span').at(0); expect(count.text()).toContain('1'); - expect(count.find(Icon).props('name')).toBe('thumb-up'); + expect(count.find(GlIcon).props('name')).toBe('thumb-up'); }); it('renders notes count', () => { @@ -121,7 +120,7 @@ describe('Issuable suggestions suggestion component', () => { const count = vm.findAll('.suggestion-counts span').at(1); expect(count.text()).toContain('2'); - expect(count.find(Icon).props('name')).toBe('comment'); + expect(count.find(GlIcon).props('name')).toBe('comment'); }); }); @@ -131,7 +130,7 @@ describe('Issuable suggestions suggestion component', () => { confidential: true, }); - const icon = vm.find(Icon); + const icon = vm.find(GlIcon); expect(icon.props('name')).toBe('eye-slash'); expect(icon.attributes('title')).toBe('Confidential'); diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index f76f42cb9ae..f4095d4de96 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -1,14 +1,22 @@ import { GlIntersectionObserver } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import { TEST_HOST } from 'helpers/test_constants'; import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import axios from '~/lib/utils/axios_utils'; import { visitUrl } from '~/lib/utils/url_utility'; import '~/behaviors/markdown/render_gfm'; import IssuableApp from '~/issue_show/components/app.vue'; import eventHub from '~/issue_show/event_hub'; -import { initialRequest, secondRequest } from '../mock_data'; +import { + appProps, + initialRequest, + publishedIncidentUrl, + secondRequest, + zoomMeetingUrl, +} from '../mock_data'; +import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; +import DescriptionComponent from '~/issue_show/components/description.vue'; +import PinnedLinks from '~/issue_show/components/pinned_links.vue'; function formatText(text) { return text.trim().replace(/\s\s+/g, ' '); @@ -19,9 +27,6 @@ jest.mock('~/issue_show/event_hub'); const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; -const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; -const publishedIncidentUrl = 'https://status.com/'; - describe('Issuable output', () => { useMockIntersectionObserver(); @@ -31,6 +36,21 @@ describe('Issuable output', () => { const findStickyHeader = () => wrapper.find('[data-testid="issue-sticky-header"]'); + const mountComponent = (props = {}, options = {}) => { + wrapper = mount(IssuableApp, { + propsData: { ...appProps, ...props }, + provide: { + fullPath: 'gitlab-org/incidents', + iid: '19', + }, + stubs: { + HighlightBar: true, + IncidentTabs: true, + }, + ...options, + }); + }; + beforeEach(() => { setFixtures(` <div> @@ -57,28 +77,9 @@ describe('Issuable output', () => { return res; }); - wrapper = mount(IssuableApp, { - propsData: { - canUpdate: true, - canDestroy: true, - endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', - updateEndpoint: TEST_HOST, - issuableRef: '#1', - issuableStatus: 'opened', - initialTitleHtml: '', - initialTitleText: '', - initialDescriptionHtml: 'test', - initialDescriptionText: 'test', - lockVersion: 1, - markdownPreviewPath: '/', - markdownDocsPath: '/', - projectNamespace: '/', - projectPath: '/', - issuableTemplateNamesPath: '/issuable-templates-path', - zoomMeetingUrl, - publishedIncidentUrl, - }, - }); + mountComponent(); + + jest.advanceTimersByTime(2); }); afterEach(() => { @@ -134,7 +135,7 @@ describe('Issuable output', () => { wrapper.vm.showForm = true; return wrapper.vm.$nextTick().then(() => { - expect(wrapper.contains('.markdown-selector')).toBe(true); + expect(wrapper.find('.markdown-selector').exists()).toBe(true); }); }); @@ -143,7 +144,7 @@ describe('Issuable output', () => { wrapper.setProps({ canUpdate: false }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.contains('.markdown-selector')).toBe(false); + expect(wrapper.find('.markdown-selector').exists()).toBe(false); }); }); @@ -403,7 +404,7 @@ describe('Issuable output', () => { .then(() => { expect(wrapper.vm.formState.lockedWarningVisible).toEqual(true); expect(wrapper.vm.formState.lock_version).toEqual(1); - expect(wrapper.contains('.alert')).toBe(true); + expect(wrapper.find('.alert').exists()).toBe(true); }); }); }); @@ -441,14 +442,14 @@ describe('Issuable output', () => { describe('show inline edit button', () => { it('should not render by default', () => { - expect(wrapper.contains('.btn-edit')).toBe(true); + expect(wrapper.find('.btn-edit').exists()).toBe(true); }); it('should render if showInlineEditButton', () => { wrapper.setProps({ showInlineEditButton: true }); return wrapper.vm.$nextTick(() => { - expect(wrapper.contains('.btn-edit')).toBe(true); + expect(wrapper.find('.btn-edit').exists()).toBe(true); }); }); }); @@ -531,7 +532,7 @@ describe('Issuable output', () => { describe('sticky header', () => { describe('when title is in view', () => { it('is not shown', () => { - expect(wrapper.contains('.issue-sticky-header')).toBe(false); + expect(wrapper.find('.issue-sticky-header').exists()).toBe(false); }); }); @@ -562,4 +563,59 @@ describe('Issuable output', () => { }); }); }); + + describe('Composable description component', () => { + const findIncidentTabs = () => wrapper.find(IncidentTabs); + const findDescriptionComponent = () => wrapper.find(DescriptionComponent); + const findPinnedLinks = () => wrapper.find(PinnedLinks); + const borderClass = 'gl-border-b-1 gl-border-b-gray-100 gl-border-b-solid gl-mb-6'; + + describe('when using description component', () => { + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('does not render incident tabs', () => { + expect(findIncidentTabs().exists()).toBe(false); + }); + + it('adds a border below the header', () => { + expect(findPinnedLinks().attributes('class')).toContain(borderClass); + }); + }); + + describe('when using incident tabs description wrapper', () => { + beforeEach(() => { + mountComponent( + { + descriptionComponent: IncidentTabs, + showTitleBorder: false, + }, + { + mocks: { + $apollo: { + queries: { + alert: { + loading: false, + }, + }, + }, + }, + }, + ); + }); + + it('renders the description component', () => { + expect(findDescriptionComponent().exists()).toBe(true); + }); + + it('renders incident tabs', () => { + expect(findIncidentTabs().exists()).toBe(true); + }); + + it('does not add a border below the header', () => { + expect(findPinnedLinks().attributes('class')).not.toContain(borderClass); + }); + }); + }); }); diff --git a/spec/frontend/issue_show/components/description_spec.js b/spec/frontend/issue_show/components/description_spec.js index 0053475dd13..bc7511225a0 100644 --- a/spec/frontend/issue_show/components/description_spec.js +++ b/spec/frontend/issue_show/components/description_spec.js @@ -5,20 +5,13 @@ import mountComponent from 'helpers/vue_mount_component_helper'; import { TEST_HOST } from 'helpers/test_constants'; import Description from '~/issue_show/components/description.vue'; import TaskList from '~/task_list'; +import { descriptionProps as props } from '../mock_data'; jest.mock('~/task_list'); describe('Description component', () => { let vm; let DescriptionComponent; - const props = { - canUpdate: true, - descriptionHtml: 'test', - descriptionText: 'test', - updatedAt: new Date().toString(), - taskStatus: '', - updateUrl: TEST_HOST, - }; beforeEach(() => { DescriptionComponent = Vue.extend(Description); @@ -43,12 +36,27 @@ describe('Description component', () => { $('.issuable-meta .flash-container').remove(); }); - it('animates description changes', () => { + it('doesnt animate first description changes', () => { vm.descriptionHtml = 'changed'; + return vm.$nextTick().then(() => { + expect( + vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'), + ).toBeFalsy(); + jest.runAllTimers(); + return vm.$nextTick(); + }); + }); + + it('animates description changes on live update', () => { + vm.descriptionHtml = 'changed'; return vm .$nextTick() .then(() => { + vm.descriptionHtml = 'changed second time'; + return vm.$nextTick(); + }) + .then(() => { expect( vm.$el.querySelector('.md').classList.contains('issue-realtime-pre-pulse'), ).toBeTruthy(); diff --git a/spec/frontend/issue_show/components/edit_actions_spec.js b/spec/frontend/issue_show/components/edit_actions_spec.js index b0c1894058e..79a2bcd5eab 100644 --- a/spec/frontend/issue_show/components/edit_actions_spec.js +++ b/spec/frontend/issue_show/components/edit_actions_spec.js @@ -70,16 +70,6 @@ describe('Edit Actions components', () => { expect(eventHub.$emit).toHaveBeenCalledWith('update.issuable'); }); - it('shows loading icon after clicking save button', done => { - vm.$el.querySelector('.btn-success').click(); - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-success .fa')).not.toBeNull(); - - done(); - }); - }); - it('disabled button after clicking save button', done => { vm.$el.querySelector('.btn-success').click(); @@ -107,17 +97,6 @@ describe('Edit Actions components', () => { expect(eventHub.$emit).toHaveBeenCalledWith('delete.issuable', { destroy_confirm: true }); }); - it('shows loading icon after clicking delete button', done => { - jest.spyOn(window, 'confirm').mockReturnValue(true); - vm.$el.querySelector('.btn-danger').click(); - - Vue.nextTick(() => { - expect(vm.$el.querySelector('.btn-danger .fa')).not.toBeNull(); - - done(); - }); - }); - it('does no actions when confirm is false', done => { jest.spyOn(window, 'confirm').mockReturnValue(false); vm.$el.querySelector('.btn-danger').click(); diff --git a/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js new file mode 100644 index 00000000000..8d50df5e406 --- /dev/null +++ b/spec/frontend/issue_show/components/incidents/highlight_bar_spec.js @@ -0,0 +1,56 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink } from '@gitlab/ui'; +import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; +import { formatDate } from '~/lib/utils/datetime_utility'; + +jest.mock('~/lib/utils/datetime_utility'); + +describe('Highlight Bar', () => { + let wrapper; + + const alert = { + startedAt: '2020-05-29T10:39:22Z', + detailsUrl: 'http://127.0.0.1:3000/root/unique-alerts/-/alert_management/1/details', + eventCount: 1, + title: 'Alert 1', + }; + + const mountComponent = () => { + wrapper = shallowMount(HighlightBar, { + propsData: { + alert, + }, + }); + }; + + beforeEach(() => { + mountComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findLink = () => wrapper.find(GlLink); + + it('renders a link to the alert page', () => { + expect(findLink().exists()).toBe(true); + expect(findLink().attributes('href')).toBe(alert.detailsUrl); + expect(findLink().text()).toContain(alert.title); + }); + + it('renders formatted start time of the alert', () => { + const formattedDate = '2020-05-29 UTC'; + formatDate.mockReturnValueOnce(formattedDate); + mountComponent(); + expect(formatDate).toHaveBeenCalledWith(alert.startedAt, 'yyyy-mm-dd Z'); + expect(wrapper.text()).toContain(formattedDate); + }); + + it('renders a number of alert events', () => { + expect(wrapper.text()).toContain(alert.eventCount); + }); +}); diff --git a/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js new file mode 100644 index 00000000000..a51b497cd79 --- /dev/null +++ b/spec/frontend/issue_show/components/incidents/incident_tabs_spec.js @@ -0,0 +1,101 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTab } from '@gitlab/ui'; +import INVALID_URL from '~/lib/utils/invalid_url'; +import IncidentTabs from '~/issue_show/components/incidents/incident_tabs.vue'; +import { descriptionProps } from '../../mock_data'; +import DescriptionComponent from '~/issue_show/components/description.vue'; +import HighlightBar from '~/issue_show/components/incidents/highlight_bar.vue'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; + +const mockAlert = { + __typename: 'AlertManagementAlert', + detailsUrl: INVALID_URL, + iid: '1', +}; + +describe('Incident Tabs component', () => { + let wrapper; + + const mountComponent = (data = {}) => { + wrapper = shallowMount(IncidentTabs, { + propsData: { + ...descriptionProps, + }, + stubs: { + DescriptionComponent: true, + }, + provide: { + fullPath: '', + iid: '', + }, + data() { + return { alert: mockAlert, ...data }; + }, + mocks: { + $apollo: { + queries: { + alert: { + loading: true, + }, + }, + }, + }, + }); + }; + + const findTabs = () => wrapper.findAll(GlTab); + const findSummaryTab = () => findTabs().at(0); + const findAlertDetailsTab = () => findTabs().at(1); + const findAlertDetailsComponent = () => wrapper.find(AlertDetailsTable); + const findDescriptionComponent = () => wrapper.find(DescriptionComponent); + const findHighlightBarComponent = () => wrapper.find(HighlightBar); + + describe('empty state', () => { + beforeEach(() => { + mountComponent({ alert: null }); + }); + + it('does not show the alert details tab', () => { + expect(findAlertDetailsComponent().exists()).toBe(false); + expect(findHighlightBarComponent().exists()).toBe(false); + }); + }); + + describe('with an alert present', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders the summary tab', () => { + expect(findSummaryTab().exists()).toBe(true); + expect(findSummaryTab().attributes('title')).toBe('Summary'); + }); + + it('renders the alert details tab', () => { + expect(findAlertDetailsTab().exists()).toBe(true); + expect(findAlertDetailsTab().attributes('title')).toBe('Alert details'); + }); + + it('renders the alert details table with the correct props', () => { + const alert = { iid: mockAlert.iid }; + + expect(findAlertDetailsComponent().props('alert')).toEqual(alert); + expect(findAlertDetailsComponent().props('loading')).toBe(true); + }); + + it('renders the description component with highlight bar', () => { + expect(findDescriptionComponent().exists()).toBe(true); + expect(findHighlightBarComponent().exists()).toBe(true); + }); + + it('renders the highlight bar component with the correct props', () => { + const alert = { detailsUrl: mockAlert.detailsUrl }; + + expect(findHighlightBarComponent().props('alert')).toMatchObject(alert); + }); + + it('passes all props to the description component', () => { + expect(findDescriptionComponent().props()).toMatchObject(descriptionProps); + }); + }); +}); diff --git a/spec/frontend/issue_show/helpers.js b/spec/frontend/issue_show/helpers.js index 5d2ced98ae4..7ca6a22929d 100644 --- a/spec/frontend/issue_show/helpers.js +++ b/spec/frontend/issue_show/helpers.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const keyboardDownEvent = (code, metaKey = false, ctrlKey = false) => { const e = new CustomEvent('keydown'); diff --git a/spec/frontend/issue_show/index_spec.js b/spec/frontend/issue_show/index_spec.js deleted file mode 100644 index e80d1b83c11..00000000000 --- a/spec/frontend/issue_show/index_spec.js +++ /dev/null @@ -1,19 +0,0 @@ -import initIssueableApp from '~/issue_show'; - -describe('Issue show index', () => { - describe('initIssueableApp', () => { - it('should initialize app with no potential XSS attack', () => { - const d = document.createElement('div'); - d.id = 'js-issuable-app-initial-data'; - d.innerHTML = JSON.stringify({ - initialDescriptionHtml: '<img src=x onerror=alert(1)>', - }); - document.body.appendChild(d); - - const alertSpy = jest.spyOn(window, 'alert'); - initIssueableApp(); - - expect(alertSpy).not.toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/issue_show/issue_spec.js b/spec/frontend/issue_show/issue_spec.js new file mode 100644 index 00000000000..befb670c6cd --- /dev/null +++ b/spec/frontend/issue_show/issue_spec.js @@ -0,0 +1,45 @@ +import MockAdapter from 'axios-mock-adapter'; +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import initIssuableApp from '~/issue_show/issue'; +import * as parseData from '~/issue_show/utils/parse_data'; +import { appProps } from './mock_data'; + +const mock = new MockAdapter(axios); +mock.onGet().reply(200); + +useMockIntersectionObserver(); + +jest.mock('~/lib/utils/poll'); + +const setupHTML = initialData => { + document.body.innerHTML = ` + <div id="js-issuable-app"></div> + <script id="js-issuable-app-initial-data" type="application/json"> + ${JSON.stringify(initialData)} + </script> + `; +}; + +describe('Issue show index', () => { + describe('initIssueableApp', () => { + it('should initialize app with no potential XSS attack', async () => { + const alertSpy = jest.spyOn(window, 'alert').mockImplementation(() => {}); + const parseDataSpy = jest.spyOn(parseData, 'parseIssuableData'); + + setupHTML({ + ...appProps, + initialDescriptionHtml: '<svg onload=window.alert(1)>', + }); + + const issuableData = parseData.parseIssuableData(); + initIssuableApp(issuableData); + + await waitForPromises(); + + expect(parseDataSpy).toHaveBeenCalled(); + expect(alertSpy).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/issue_show/mock_data.js b/spec/frontend/issue_show/mock_data.js index ff01a004186..5a31a550088 100644 --- a/spec/frontend/issue_show/mock_data.js +++ b/spec/frontend/issue_show/mock_data.js @@ -1,3 +1,5 @@ +import { TEST_HOST } from 'helpers/test_constants'; + export const initialRequest = { title: '<p>this is a title</p>', title_text: 'this is a title', @@ -21,3 +23,36 @@ export const secondRequest = { updated_by_path: '/other_user', lock_version: 2, }; + +export const descriptionProps = { + canUpdate: true, + descriptionHtml: 'test', + descriptionText: 'test', + taskStatus: '', + updateUrl: TEST_HOST, +}; + +export const publishedIncidentUrl = 'https://status.com/'; + +export const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; + +export const appProps = { + canUpdate: true, + canDestroy: true, + endpoint: '/gitlab-org/gitlab-shell/-/issues/9/realtime_changes', + updateEndpoint: TEST_HOST, + issuableRef: '#1', + issuableStatus: 'opened', + initialTitleHtml: '', + initialTitleText: '', + initialDescriptionHtml: 'test', + initialDescriptionText: 'test', + lockVersion: 1, + markdownPreviewPath: '/', + markdownDocsPath: '/', + projectNamespace: '/', + projectPath: '/', + issuableTemplateNamesPath: '/issuable-templates-path', + zoomMeetingUrl, + publishedIncidentUrl, +}; diff --git a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap index c327b7de827..c327b7de827 100644 --- a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap +++ b/spec/frontend/issues_list/components/__snapshots__/issuables_list_app_spec.js.snap diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issues_list/components/issuable_spec.js index 6ede46a602a..c20684cc385 100644 --- a/spec/frontend/issuables_list/components/issuable_spec.js +++ b/spec/frontend/issues_list/components/issuable_spec.js @@ -5,7 +5,7 @@ import { trimText } from 'helpers/text_helper'; import initUserPopovers from '~/user_popovers'; import { formatDate } from '~/lib/utils/datetime_utility'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import Issuable from '~/issuables_list/components/issuable.vue'; +import Issuable from '~/issues_list/components/issuable.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; import { simpleIssue, testAssignees, testLabels } from '../issuable_list_test_data'; import { isScopedLabel } from '~/lib/utils/common_utils'; @@ -52,7 +52,6 @@ describe('Issuable component', () => { }, stubs: { 'gl-sprintf': GlSprintf, - 'gl-link': '<a><slot></slot></a>', }, }); }; @@ -98,7 +97,7 @@ describe('Issuable component', () => { const findUnscopedLabels = () => findLabels().filter(w => !isScopedLabel({ title: w.text() })); const findIssuableTitle = () => wrapper.find('[data-testid="issuable-title"]'); const findIssuableStatus = () => wrapper.find('[data-testid="issuable-status"]'); - const containsJiraLogo = () => wrapper.contains('[data-testid="jira-logo"]'); + const containsJiraLogo = () => wrapper.find('[data-testid="jira-logo"]').exists(); const findHealthStatus = () => wrapper.find('.health-status'); describe('when mounted', () => { diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issues_list/components/issuables_list_app_spec.js index 65b87ddf6a6..1f80b4fc54a 100644 --- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issues_list/components/issuables_list_app_spec.js @@ -1,18 +1,22 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; +import { + GlEmptyState, + GlPagination, + GlDeprecatedSkeletonLoading as GlSkeletonLoading, +} from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; import { deprecatedCreateFlash as flash } from '~/flash'; -import IssuablesListApp from '~/issuables_list/components/issuables_list_app.vue'; -import Issuable from '~/issuables_list/components/issuable.vue'; +import IssuablesListApp from '~/issues_list/components/issuables_list_app.vue'; +import Issuable from '~/issues_list/components/issuable.vue'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import issueablesEventBus from '~/issuables_list/eventhub'; -import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issuables_list/constants'; +import issueablesEventBus from '~/issues_list/eventhub'; +import { PAGE_SIZE, PAGE_SIZE_MANUAL, RELATIVE_POSITION } from '~/issues_list/constants'; jest.mock('~/flash'); -jest.mock('~/issuables_list/eventhub'); +jest.mock('~/issues_list/eventhub'); jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), scrollToElement: () => {}, @@ -169,7 +173,7 @@ describe('Issuables list component', () => { it('does not display empty state', () => { expect(wrapper.vm.issuables.length).toBeGreaterThan(0); expect(wrapper.vm.emptyState).toEqual({}); - expect(wrapper.contains(GlEmptyState)).toBe(false); + expect(wrapper.find(GlEmptyState).exists()).toBe(false); }); it('sets the proper page and total items', () => { diff --git a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js b/spec/frontend/issues_list/components/jira_issues_list_root_spec.js index aee49076b5d..eecb092a330 100644 --- a/spec/frontend/issuables_list/components/issuable_list_root_app_spec.js +++ b/spec/frontend/issues_list/components/jira_issues_list_root_spec.js @@ -1,9 +1,9 @@ import { GlAlert, GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import IssuableListRootApp from '~/issuables_list/components/issuable_list_root_app.vue'; +import JiraIssuesListRoot from '~/issues_list/components/jira_issues_list_root.vue'; -describe('IssuableListRootApp', () => { +describe('JiraIssuesListRoot', () => { const issuesPath = 'gitlab-org/gitlab-test/-/issues'; const label = { color: '#333', @@ -19,7 +19,7 @@ describe('IssuableListRootApp', () => { shouldShowFinishedAlert = false, shouldShowInProgressAlert = false, } = {}) => - shallowMount(IssuableListRootApp, { + shallowMount(JiraIssuesListRoot, { propsData: { canEdit: true, isJiraConfigured: true, @@ -47,7 +47,7 @@ describe('IssuableListRootApp', () => { it('does not show an alert', () => { wrapper = mountComponent(); - expect(wrapper.contains(GlAlert)).toBe(false); + expect(wrapper.find(GlAlert).exists()).toBe(false); }); }); @@ -103,12 +103,12 @@ describe('IssuableListRootApp', () => { shouldShowInProgressAlert: true, }); - expect(wrapper.contains(GlAlert)).toBe(true); + expect(wrapper.find(GlAlert).exists()).toBe(true); findAlert().vm.$emit('dismiss'); return Vue.nextTick(() => { - expect(wrapper.contains(GlAlert)).toBe(false); + expect(wrapper.find(GlAlert).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/issuables_list/issuable_list_test_data.js b/spec/frontend/issues_list/issuable_list_test_data.js index 313aa15bd31..313aa15bd31 100644 --- a/spec/frontend/issuables_list/issuable_list_test_data.js +++ b/spec/frontend/issues_list/issuable_list_test_data.js diff --git a/spec/frontend/issues_list/service_desk_helper_spec.js b/spec/frontend/issues_list/service_desk_helper_spec.js new file mode 100644 index 00000000000..16aee853341 --- /dev/null +++ b/spec/frontend/issues_list/service_desk_helper_spec.js @@ -0,0 +1,28 @@ +import { emptyStateHelper, generateMessages } from '~/issues_list/service_desk_helper'; + +describe('service desk helper', () => { + const emptyStateMessages = generateMessages({}); + + // Note: isServiceDeskEnabled must not be true when isServiceDeskSupported is false (it's an invalid case). + describe.each` + isServiceDeskSupported | isServiceDeskEnabled | canEditProjectSettings | expectedMessage + ${true} | ${true} | ${true} | ${'serviceDeskEnabledAndCanEditProjectSettings'} + ${true} | ${true} | ${false} | ${'serviceDeskEnabledAndCannotEditProjectSettings'} + ${true} | ${false} | ${true} | ${'serviceDeskDisabledAndCanEditProjectSettings'} + ${true} | ${false} | ${false} | ${'serviceDeskDisabledAndCannotEditProjectSettings'} + ${false} | ${false} | ${true} | ${'serviceDeskIsNotSupported'} + ${false} | ${false} | ${false} | ${'serviceDeskIsNotEnabled'} + `( + 'isServiceDeskSupported = $isServiceDeskSupported, isServiceDeskEnabled = $isServiceDeskEnabled, canEditProjectSettings = $canEditProjectSettings', + ({ isServiceDeskSupported, isServiceDeskEnabled, canEditProjectSettings, expectedMessage }) => { + it(`displays ${expectedMessage} message`, () => { + const emptyStateMeta = { + isServiceDeskEnabled, + isServiceDeskSupported, + canEditProjectSettings, + }; + expect(emptyStateHelper(emptyStateMeta)).toEqual(emptyStateMessages[expectedMessage]); + }); + }, + ); +}); diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index 975c31bb59c..eede5426f42 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -114,7 +114,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <div - class="gl-search-box-by-type m-2" + class="gl-search-box-by-type gl-m-3" > <svg class="gl-search-box-by-type-search-icon gl-icon s16" @@ -225,7 +225,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <div - class="gl-search-box-by-type m-2" + class="gl-search-box-by-type gl-m-3" > <svg class="gl-search-box-by-type-search-icon gl-icon s16" diff --git a/spec/frontend/jira_import/components/jira_import_form_spec.js b/spec/frontend/jira_import/components/jira_import_form_spec.js index 6ef28a71f48..d184c054b8a 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlButton, GlNewDropdown, GlFormSelect, GlLabel, GlTable } from '@gitlab/ui'; +import { GlAlert, GlButton, GlDropdown, GlFormSelect, GlLabel, GlTable } from '@gitlab/ui'; import { getByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -35,7 +35,7 @@ describe('JiraImportForm', () => { const getTable = () => wrapper.find(GlTable); - const getUserDropdown = () => getTable().find(GlNewDropdown); + const getUserDropdown = () => getTable().find(GlDropdown); const getHeader = name => getByRole(wrapper.element, 'columnheader', { name }); @@ -100,7 +100,7 @@ describe('JiraImportForm', () => { it('is shown', () => { wrapper = mountComponent(); - expect(wrapper.contains(GlFormSelect)).toBe(true); + expect(wrapper.find(GlFormSelect).exists()).toBe(true); }); it('contains a list of Jira projects to select from', () => { diff --git a/spec/frontend/jira_import/utils/jira_import_utils_spec.js b/spec/frontend/jira_import/utils/jira_import_utils_spec.js index 8ae1fc3535a..0992c9e8d16 100644 --- a/spec/frontend/jira_import/utils/jira_import_utils_spec.js +++ b/spec/frontend/jira_import/utils/jira_import_utils_spec.js @@ -8,7 +8,7 @@ import { setFinishedAlertHideMap, shouldShowFinishedAlert, } from '~/jira_import/utils/jira_import_utils'; -import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issuables_list/constants'; +import { JIRA_IMPORT_SUCCESS_ALERT_HIDE_MAP_KEY } from '~/issues_list/constants'; useLocalStorageSpy(); diff --git a/spec/frontend/jobs/components/artifacts_block_spec.js b/spec/frontend/jobs/components/artifacts_block_spec.js index 11bd645916e..a709a59cadd 100644 --- a/spec/frontend/jobs/components/artifacts_block_spec.js +++ b/spec/frontend/jobs/components/artifacts_block_spec.js @@ -8,7 +8,10 @@ describe('Artifacts block', () => { const createWrapper = propsData => mount(ArtifactsBlock, { - propsData, + propsData: { + helpUrl: 'help-url', + ...propsData, + }, }); const findArtifactRemoveElt = () => wrapper.find('[data-testid="artifacts-remove-timeline"]'); @@ -68,6 +71,12 @@ describe('Artifacts block', () => { expect(trimText(findArtifactRemoveElt().text())).toBe( `The artifacts were removed ${formattedDate}`, ); + + expect( + findArtifactRemoveElt() + .find('[data-testid="artifact-expired-help-link"]') + .attributes('href'), + ).toBe('help-url'); }); it('does not show the keep button', () => { @@ -94,6 +103,12 @@ describe('Artifacts block', () => { expect(trimText(findArtifactRemoveElt().text())).toBe( `The artifacts will be removed ${formattedDate}`, ); + + expect( + findArtifactRemoveElt() + .find('[data-testid="artifact-expired-help-link"]') + .attributes('href'), + ).toBe('help-url'); }); it('renders the keep button', () => { diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index e9ecafcd4c3..94653d4d4c7 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -33,6 +33,7 @@ describe('Job App', () => { }; const props = { + artifactHelpUrl: 'help/artifact', runnerHelpUrl: 'help/runner', deploymentHelpUrl: 'help/deployment', runnerSettingsUrl: 'settings/ci-cd/runners', diff --git a/spec/frontend/jobs/components/log/collapsible_section_spec.js b/spec/frontend/jobs/components/log/collapsible_section_spec.js index bf2f8c05806..66f22162c97 100644 --- a/spec/frontend/jobs/components/log/collapsible_section_spec.js +++ b/spec/frontend/jobs/components/log/collapsible_section_spec.js @@ -35,7 +35,7 @@ describe('Job Log Collapsible Section', () => { }); it('renders an icon with the closed state', () => { - expect(findCollapsibleLineSvg().classes()).toContain('ic-angle-right'); + expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('angle-right-icon'); }); }); @@ -52,7 +52,7 @@ describe('Job Log Collapsible Section', () => { }); it('renders an icon with the open state', () => { - expect(findCollapsibleLineSvg().classes()).toContain('ic-angle-down'); + expect(findCollapsibleLineSvg().attributes('data-testid')).toBe('angle-down-icon'); }); it('renders collapsible lines content', () => { diff --git a/spec/frontend/jobs/components/log/line_header_spec.js b/spec/frontend/jobs/components/log/line_header_spec.js index 5ce69221dab..bb90949b1f4 100644 --- a/spec/frontend/jobs/components/log/line_header_spec.js +++ b/spec/frontend/jobs/components/log/line_header_spec.js @@ -38,7 +38,7 @@ describe('Job Log Header Line', () => { }); it('renders the line number component', () => { - expect(wrapper.contains(LineNumber)).toBe(true); + expect(wrapper.find(LineNumber).exists()).toBe(true); }); it('renders a span the provided text', () => { @@ -90,7 +90,7 @@ describe('Job Log Header Line', () => { }); it('renders the duration badge', () => { - expect(wrapper.contains(DurationBadge)).toBe(true); + expect(wrapper.find(DurationBadge).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/log/line_spec.js b/spec/frontend/jobs/components/log/line_spec.js index ec3a3968f14..c2412a807c3 100644 --- a/spec/frontend/jobs/components/log/line_spec.js +++ b/spec/frontend/jobs/components/log/line_spec.js @@ -35,7 +35,7 @@ describe('Job Log Line', () => { }); it('renders the line number component', () => { - expect(wrapper.contains(LineNumber)).toBe(true); + expect(wrapper.find(LineNumber).exists()).toBe(true); }); it('renders a span the provided text', () => { diff --git a/spec/frontend/jobs/components/log/log_spec.js b/spec/frontend/jobs/components/log/log_spec.js index 02cdb31d27e..015d5e01a46 100644 --- a/spec/frontend/jobs/components/log/log_spec.js +++ b/spec/frontend/jobs/components/log/log_spec.js @@ -42,6 +42,8 @@ describe('Job Log', () => { wrapper.destroy(); }); + const findCollapsibleLine = () => wrapper.find('.collapsible-line'); + describe('line numbers', () => { it('renders a line number for each open line', () => { expect(wrapper.find('#L1').text()).toBe('1'); @@ -56,18 +58,22 @@ describe('Job Log', () => { describe('collapsible sections', () => { it('renders a clickable header section', () => { - expect(wrapper.find('.collapsible-line').attributes('role')).toBe('button'); + expect(findCollapsibleLine().attributes('role')).toBe('button'); }); it('renders an icon with the open state', () => { - expect(wrapper.find('.collapsible-line svg').classes()).toContain('ic-angle-down'); + expect( + findCollapsibleLine() + .find('[data-testid="angle-down-icon"]') + .exists(), + ).toBe(true); }); describe('on click header section', () => { it('calls toggleCollapsibleLine', () => { jest.spyOn(wrapper.vm, 'toggleCollapsibleLine'); - wrapper.find('.collapsible-line').trigger('click'); + findCollapsibleLine().trigger('click'); expect(wrapper.vm.toggleCollapsibleLine).toHaveBeenCalled(); }); diff --git a/spec/frontend/jobs/components/manual_variables_form_spec.js b/spec/frontend/jobs/components/manual_variables_form_spec.js index 82fd73ef033..547f146cf88 100644 --- a/spec/frontend/jobs/components/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/manual_variables_form_spec.js @@ -1,5 +1,5 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import Form from '~/jobs/components/manual_variables_form.vue'; const localVue = createLocalVue(); @@ -95,7 +95,7 @@ describe('Manual Variables Form', () => { }); it('removes the variable row', () => { - wrapper.find(GlDeprecatedButton).vm.$emit('click'); + wrapper.find(GlButton).vm.$emit('click'); expect(wrapper.vm.variables.length).toBe(0); }); diff --git a/spec/frontend/jobs/store/helpers.js b/spec/frontend/jobs/store/helpers.js index 81a769b4a6e..78e33394b63 100644 --- a/spec/frontend/jobs/store/helpers.js +++ b/spec/frontend/jobs/store/helpers.js @@ -1,6 +1,5 @@ import state from '~/jobs/store/state'; -// eslint-disable-next-line import/prefer-default-export export const resetStore = store => { store.replaceState(state()); }; diff --git a/spec/frontend/labels_issue_sidebar_spec.js b/spec/frontend/labels_issue_sidebar_spec.js index fafefca94df..f74547c0554 100644 --- a/spec/frontend/labels_issue_sidebar_spec.js +++ b/spec/frontend/labels_issue_sidebar_spec.js @@ -7,7 +7,6 @@ import axios from '~/lib/utils/axios_utils'; import IssuableContext from '~/issuable_context'; import LabelsSelect from '~/labels_select'; -import '~/gl_dropdown'; import 'select2'; import '~/api'; import '~/create_label'; diff --git a/spec/frontend/lib/utils/axios_startup_calls_spec.js b/spec/frontend/lib/utils/axios_startup_calls_spec.js new file mode 100644 index 00000000000..e804cae7914 --- /dev/null +++ b/spec/frontend/lib/utils/axios_startup_calls_spec.js @@ -0,0 +1,131 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import setupAxiosStartupCalls from '~/lib/utils/axios_startup_calls'; + +describe('setupAxiosStartupCalls', () => { + const AXIOS_RESPONSE = { text: 'AXIOS_RESPONSE' }; + const STARTUP_JS_RESPONSE = { text: 'STARTUP_JS_RESPONSE' }; + let mock; + + function mockFetchCall(status) { + const p = { + ok: status >= 200 && status < 300, + status, + headers: new Headers({ 'Content-Type': 'application/json' }), + statusText: `MOCK-FETCH ${status}`, + clone: () => p, + json: () => Promise.resolve(STARTUP_JS_RESPONSE), + }; + return Promise.resolve(p); + } + + function mockConsoleWarn() { + jest.spyOn(console, 'warn').mockImplementation(); + } + + function expectConsoleWarn(path) { + // eslint-disable-next-line no-console + expect(console.warn).toHaveBeenCalledWith(expect.stringMatching(path), expect.any(Error)); + } + + beforeEach(() => { + window.gl = {}; + mock = new MockAdapter(axios); + mock.onGet('/non-startup').reply(200, AXIOS_RESPONSE); + mock.onGet('/startup').reply(200, AXIOS_RESPONSE); + mock.onGet('/startup-failing').reply(200, AXIOS_RESPONSE); + }); + + afterEach(() => { + delete window.gl; + axios.interceptors.request.handlers = []; + mock.restore(); + }); + + it('if no startupCalls are registered: does not register a request interceptor', () => { + setupAxiosStartupCalls(axios); + + expect(axios.interceptors.request.handlers.length).toBe(0); + }); + + describe('if startupCalls are registered', () => { + beforeEach(() => { + window.gl.startup_calls = { + '/startup': { + fetchCall: mockFetchCall(200), + }, + '/startup-failing': { + fetchCall: mockFetchCall(400), + }, + }; + setupAxiosStartupCalls(axios); + }); + + it('registers a request interceptor', () => { + expect(axios.interceptors.request.handlers.length).toBe(1); + }); + + it('detaches the request interceptor if every startup call has been made', async () => { + expect(axios.interceptors.request.handlers[0]).not.toBeNull(); + + await axios.get('/startup'); + mockConsoleWarn(); + await axios.get('/startup-failing'); + + // Axios sets the interceptor to null + expect(axios.interceptors.request.handlers[0]).toBeNull(); + }); + + it('delegates to startup calls if URL is registered and call is successful', async () => { + const { headers, data, status, statusText } = await axios.get('/startup'); + + expect(headers).toEqual({ 'content-type': 'application/json' }); + expect(status).toBe(200); + expect(statusText).toBe('MOCK-FETCH 200'); + expect(data).toEqual(STARTUP_JS_RESPONSE); + expect(data).not.toEqual(AXIOS_RESPONSE); + }); + + it('delegates to startup calls exactly once', async () => { + await axios.get('/startup'); + const { data } = await axios.get('/startup'); + + expect(data).not.toEqual(STARTUP_JS_RESPONSE); + expect(data).toEqual(AXIOS_RESPONSE); + }); + + it('does not delegate to startup calls if the call is failing', async () => { + mockConsoleWarn(); + const { data } = await axios.get('/startup-failing'); + + expect(data).not.toEqual(STARTUP_JS_RESPONSE); + expect(data).toEqual(AXIOS_RESPONSE); + expectConsoleWarn('/startup-failing'); + }); + + it('does not delegate to startup call if URL is not registered', async () => { + const { data } = await axios.get('/non-startup'); + + expect(data).toEqual(AXIOS_RESPONSE); + expect(data).not.toEqual(STARTUP_JS_RESPONSE); + }); + }); + + it('removes GitLab Base URL from startup call', async () => { + const oldGon = window.gon; + window.gon = { gitlab_url: 'https://example.org/gitlab' }; + + window.gl.startup_calls = { + '/startup': { + fetchCall: mockFetchCall(200), + }, + }; + setupAxiosStartupCalls(axios); + + const { data } = await axios.get('https://example.org/gitlab/startup'); + + expect(data).toEqual(STARTUP_JS_RESPONSE); + + window.gon = oldGon; + }); +}); diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index 9eb5587e83c..5b1fdea058b 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -653,3 +653,17 @@ describe('differenceInSeconds', () => { expect(datetimeUtility.differenceInSeconds(startDate, endDate)).toBe(expected); }); }); + +describe('differenceInMilliseconds', () => { + const startDateTime = new Date('2019-07-17T00:00:00.000Z'); + + it.each` + startDate | endDate | expected + ${startDateTime.getTime()} | ${new Date('2019-07-17T00:00:00.000Z')} | ${0} + ${startDateTime} | ${new Date('2019-07-17T12:00:00.000Z').getTime()} | ${43200000} + ${startDateTime} | ${new Date('2019-07-18T00:00:00.000Z').getTime()} | ${86400000} + ${new Date('2019-07-18T00:00:00.000Z')} | ${startDateTime.getTime()} | ${-86400000} + `('returns $expected for $endDate - $startDate', ({ startDate, endDate, expected }) => { + expect(datetimeUtility.differenceInMilliseconds(startDate, endDate)).toBe(expected); + }); +}); diff --git a/spec/frontend/lib/utils/forms_spec.js b/spec/frontend/lib/utils/forms_spec.js index 07ba7c29dfc..a69be99ab98 100644 --- a/spec/frontend/lib/utils/forms_spec.js +++ b/spec/frontend/lib/utils/forms_spec.js @@ -1,4 +1,4 @@ -import { serializeForm } from '~/lib/utils/forms'; +import { serializeForm, serializeFormObject, isEmptyValue } from '~/lib/utils/forms'; describe('lib/utils/forms', () => { const createDummyForm = inputs => { @@ -93,4 +93,46 @@ describe('lib/utils/forms', () => { }); }); }); + + describe('isEmptyValue', () => { + it.each` + input | returnValue + ${''} | ${true} + ${[]} | ${true} + ${null} | ${true} + ${undefined} | ${true} + ${'hello'} | ${false} + ${' '} | ${false} + ${0} | ${false} + `('returns $returnValue for value $input', ({ input, returnValue }) => { + expect(isEmptyValue(input)).toBe(returnValue); + }); + }); + + describe('serializeFormObject', () => { + it('returns an serialized object', () => { + const form = { + profileName: { value: 'hello', state: null, feedback: null }, + spiderTimeout: { value: 2, state: true, feedback: null }, + targetTimeout: { value: 12, state: true, feedback: null }, + }; + expect(serializeFormObject(form)).toEqual({ + profileName: 'hello', + spiderTimeout: 2, + targetTimeout: 12, + }); + }); + + it('returns only the entries with value', () => { + const form = { + profileName: { value: '', state: null, feedback: null }, + spiderTimeout: { value: 0, state: null, feedback: null }, + targetTimeout: { value: null, state: null, feedback: null }, + name: { value: undefined, state: null, feedback: null }, + }; + expect(serializeFormObject(form)).toEqual({ + spiderTimeout: 0, + }); + }); + }); }); diff --git a/spec/frontend/lib/utils/text_markdown_spec.js b/spec/frontend/lib/utils/text_markdown_spec.js index 2e52958a828..1aaae80dcdf 100644 --- a/spec/frontend/lib/utils/text_markdown_spec.js +++ b/spec/frontend/lib/utils/text_markdown_spec.js @@ -1,4 +1,4 @@ -import { insertMarkdownText } from '~/lib/utils/text_markdown'; +import { insertMarkdownText, keypressNoteText } from '~/lib/utils/text_markdown'; describe('init markdown', () => { let textArea; @@ -115,14 +115,15 @@ describe('init markdown', () => { describe('with selection', () => { const text = 'initial selected value'; const selected = 'selected'; + let selectedIndex; + beforeEach(() => { textArea.value = text; - const selectedIndex = text.indexOf(selected); + selectedIndex = text.indexOf(selected); textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length); }); it('applies the tag to the selected value', () => { - const selectedIndex = text.indexOf(selected); const tag = '*'; insertMarkdownText({ @@ -153,6 +154,29 @@ describe('init markdown', () => { expect(textArea.value).toEqual(text.replace(selected, `[${selected}](url)`)); }); + it.each` + key | expected + ${'['} | ${`[${selected}]`} + ${'*'} | ${`**${selected}**`} + ${"'"} | ${`'${selected}'`} + ${'_'} | ${`_${selected}_`} + ${'`'} | ${`\`${selected}\``} + ${'"'} | ${`"${selected}"`} + ${'{'} | ${`{${selected}}`} + ${'('} | ${`(${selected})`} + ${'<'} | ${`<${selected}>`} + `('generates $expected when $key is pressed', ({ key, expected }) => { + const event = new KeyboardEvent('keydown', { key }); + + textArea.addEventListener('keydown', keypressNoteText); + textArea.dispatchEvent(event); + + expect(textArea.value).toEqual(text.replace(selected, expected)); + + // cursor placement should be after selection + 2 tag lengths + expect(textArea.selectionStart).toBe(selectedIndex + expected.length); + }); + describe('and text to be selected', () => { const tag = '[{text}](url)'; const select = 'url'; @@ -178,7 +202,7 @@ describe('init markdown', () => { it('selects the right text when multiple tags are present', () => { const initialValue = `${tag} ${tag} ${selected}`; textArea.value = initialValue; - const selectedIndex = initialValue.indexOf(selected); + selectedIndex = initialValue.indexOf(selected); textArea.setSelectionRange(selectedIndex, selectedIndex + selected.length); insertMarkdownText({ textArea, @@ -204,7 +228,7 @@ describe('init markdown', () => { const initialValue = `text ${expectedUrl} text`; textArea.value = initialValue; - const selectedIndex = initialValue.indexOf(expectedUrl); + selectedIndex = initialValue.indexOf(expectedUrl); textArea.setSelectionRange(selectedIndex, selectedIndex + expectedUrl.length); insertMarkdownText({ diff --git a/spec/frontend/lib/utils/text_utility_spec.js b/spec/frontend/lib/utils/text_utility_spec.js index 285f7d04c3b..6fef5f6b63c 100644 --- a/spec/frontend/lib/utils/text_utility_spec.js +++ b/spec/frontend/lib/utils/text_utility_spec.js @@ -205,6 +205,27 @@ describe('text_utility', () => { }); }); + describe('convertUnicodeToAscii', () => { + it('does nothing on an empty string', () => { + expect(textUtils.convertUnicodeToAscii('')).toBe(''); + }); + + it('does nothing on an already ascii string', () => { + expect(textUtils.convertUnicodeToAscii('The quick brown fox jumps over the lazy dog.')).toBe( + 'The quick brown fox jumps over the lazy dog.', + ); + }); + + it('replaces Unicode characters', () => { + expect(textUtils.convertUnicodeToAscii('Dĭd söméònê äšk fœŕ Ůnĭċődę?')).toBe( + 'Did soemeone aesk foer Unicode?', + ); + + expect(textUtils.convertUnicodeToAscii("Jürgen's Projekt")).toBe("Juergen's Projekt"); + expect(textUtils.convertUnicodeToAscii('öäüÖÄÜ')).toBe('oeaeueOeAeUe'); + }); + }); + describe('splitCamelCase', () => { it('separates a PascalCase word to two', () => { expect(textUtils.splitCamelCase('HelloWorld')).toBe('Hello World'); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index a13ac3778cf..869ae274a3f 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -161,6 +161,15 @@ describe('URL utility', () => { ); }); + it('sorts params in alphabetical order with sort option', () => { + expect(mergeUrlParams({ c: 'c', b: 'b', a: 'a' }, 'https://host/path', { sort: true })).toBe( + 'https://host/path?a=a&b=b&c=c', + ); + expect( + mergeUrlParams({ alpha: 'alpha' }, 'https://host/path?op=/&foo=bar', { sort: true }), + ).toBe('https://host/path?alpha=alpha&foo=bar&op=%2F'); + }); + describe('with spread array option', () => { const spreadArrayOptions = { spreadArrays: true }; @@ -616,6 +625,35 @@ describe('URL utility', () => { expect(urlUtils.queryToObject(searchQuery)).toEqual({ one: '1', two: '2' }); }); + + describe('with gatherArrays=false', () => { + it('overwrites values with the same array-key and does not change the key', () => { + const searchQuery = '?one[]=1&one[]=2&two=2&two=3'; + + expect(urlUtils.queryToObject(searchQuery)).toEqual({ 'one[]': '2', two: '3' }); + }); + }); + + describe('with gatherArrays=true', () => { + const options = { gatherArrays: true }; + it('gathers only values with the same array-key and strips `[]` from the key', () => { + const searchQuery = '?one[]=1&one[]=2&two=2&two=3'; + + expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['1', '2'], two: '3' }); + }); + + it('overwrites values with the same array-key name', () => { + const searchQuery = '?one=1&one[]=2&two=2&two=3'; + + expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: ['2'], two: '3' }); + }); + + it('overwrites values with the same key name', () => { + const searchQuery = '?one[]=1&one=2&two=2&two=3'; + + expect(urlUtils.queryToObject(searchQuery, options)).toEqual({ one: '2', two: '3' }); + }); + }); }); describe('objectToQuery', () => { diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index 6421aca684f..559ce4f9414 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -39,13 +39,22 @@ describe('EnvironmentLogs', () => { }; const updateControlBtnsMock = jest.fn(); + const LogControlButtonsStub = { + template: '<div/>', + methods: { + update: updateControlBtnsMock, + }, + props: { + scrollDownButtonDisabled: false, + }, + }; const findEnvironmentsDropdown = () => wrapper.find('.js-environments-dropdown'); const findSimpleFilters = () => wrapper.find({ ref: 'log-simple-filters' }); const findAdvancedFilters = () => wrapper.find({ ref: 'log-advanced-filters' }); const findElasticsearchNotice = () => wrapper.find({ ref: 'elasticsearchNotice' }); - const findLogControlButtons = () => wrapper.find({ name: 'log-control-buttons-stub' }); + const findLogControlButtons = () => wrapper.find(LogControlButtonsStub); const findInfiniteScroll = () => wrapper.find({ ref: 'infiniteScroll' }); const findLogTrace = () => wrapper.find({ ref: 'logTrace' }); @@ -76,16 +85,7 @@ describe('EnvironmentLogs', () => { propsData, store, stubs: { - LogControlButtons: { - name: 'log-control-buttons-stub', - template: '<div/>', - methods: { - update: updateControlBtnsMock, - }, - props: { - scrollDownButtonDisabled: false, - }, - }, + LogControlButtons: LogControlButtonsStub, GlInfiniteScroll: { name: 'gl-infinite-scroll', template: ` @@ -121,9 +121,6 @@ describe('EnvironmentLogs', () => { it('displays UI elements', () => { initWrapper(); - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.isEmpty()).toBe(false); - expect(findEnvironmentsDropdown().is(GlDeprecatedDropdown)).toBe(true); expect(findSimpleFilters().exists()).toBe(true); expect(findLogControlButtons().exists()).toBe(true); diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js index 007c5000e16..3a3c23c95b8 100644 --- a/spec/frontend/logs/components/log_advanced_filters_spec.js +++ b/spec/frontend/logs/components/log_advanced_filters_spec.js @@ -68,9 +68,6 @@ describe('LogAdvancedFilters', () => { it('displays UI elements', () => { initWrapper(); - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.isEmpty()).toBe(false); - expect(findFilteredSearch().exists()).toBe(true); expect(findTimeRangePicker().exists()).toBe(true); }); diff --git a/spec/frontend/logs/components/log_control_buttons_spec.js b/spec/frontend/logs/components/log_control_buttons_spec.js index 38e568f569f..dff38ecb15e 100644 --- a/spec/frontend/logs/components/log_control_buttons_spec.js +++ b/spec/frontend/logs/components/log_control_buttons_spec.js @@ -28,9 +28,6 @@ describe('LogControlButtons', () => { it('displays UI elements', () => { initWrapper(); - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.isEmpty()).toBe(false); - expect(findScrollToTop().is(GlButton)).toBe(true); expect(findScrollToBottom().is(GlButton)).toBe(true); expect(findRefreshBtn().is(GlButton)).toBe(true); @@ -57,7 +54,7 @@ describe('LogControlButtons', () => { }); it('click on "scroll to top" scrolls up', () => { - expect(findScrollToTop().is('[disabled]')).toBe(false); + expect(findScrollToTop().attributes('disabled')).toBeUndefined(); findScrollToTop().vm.$emit('click'); @@ -65,7 +62,7 @@ describe('LogControlButtons', () => { }); it('click on "scroll to bottom" scrolls down', () => { - expect(findScrollToBottom().is('[disabled]')).toBe(false); + expect(findScrollToBottom().attributes('disabled')).toBeUndefined(); findScrollToBottom().vm.$emit('click'); diff --git a/spec/frontend/logs/components/log_simple_filters_spec.js b/spec/frontend/logs/components/log_simple_filters_spec.js index e739621431e..1e30a7df559 100644 --- a/spec/frontend/logs/components/log_simple_filters_spec.js +++ b/spec/frontend/logs/components/log_simple_filters_spec.js @@ -18,7 +18,7 @@ describe('LogSimpleFilters', () => { const findPodsDropdownItems = () => findPodsDropdown() .findAll(GlDeprecatedDropdownItem) - .filter(item => !item.is('[disabled]')); + .filter(item => !('disabled' in item.attributes())); const mockPodsLoading = () => { state.pods.options = []; @@ -59,9 +59,6 @@ describe('LogSimpleFilters', () => { it('displays UI elements', () => { initWrapper(); - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.isEmpty()).toBe(false); - expect(findPodsDropdown().exists()).toBe(true); }); diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js index f4c567a2ea3..3fabab4bc59 100644 --- a/spec/frontend/logs/mock_data.js +++ b/spec/frontend/logs/mock_data.js @@ -35,6 +35,7 @@ export const mockManagedApps = [ status: 'connected', path: '/root/autodevops-deploy/-/clusters/15', gitlab_managed_apps_logs_path: '/root/autodevops-deploy/-/logs?cluster_id=15', + enable_advanced_logs_querying: true, }, { cluster_type: 'project_type', @@ -45,6 +46,7 @@ export const mockManagedApps = [ status: 'connected', path: '/root/autodevops-deploy/-/clusters/16', gitlab_managed_apps_logs_path: null, + enable_advanced_logs_querying: false, }, ]; diff --git a/spec/frontend/logs/stores/getters_spec.js b/spec/frontend/logs/stores/getters_spec.js index 9d213d8c01f..bca1ce4ca92 100644 --- a/spec/frontend/logs/stores/getters_spec.js +++ b/spec/frontend/logs/stores/getters_spec.js @@ -1,7 +1,14 @@ import { trace, showAdvancedFilters } from '~/logs/stores/getters'; import logsPageState from '~/logs/stores/state'; -import { mockLogsResult, mockTrace, mockEnvName, mockEnvironments } from '../mock_data'; +import { + mockLogsResult, + mockTrace, + mockEnvName, + mockEnvironments, + mockManagedApps, + mockManagedAppName, +} from '../mock_data'; describe('Logs Store getters', () => { let state; @@ -72,4 +79,43 @@ describe('Logs Store getters', () => { }); }); }); + + describe('when no managedApps are set', () => { + beforeEach(() => { + state.environments.current = null; + state.environments.options = []; + state.managedApps.current = mockManagedAppName; + state.managedApps.options = []; + }); + + it('returns false', () => { + expect(showAdvancedFilters(state)).toBe(false); + }); + }); + + describe('when the managedApp supports filters', () => { + beforeEach(() => { + state.environments.current = null; + state.environments.options = mockEnvironments; + state.managedApps.current = mockManagedAppName; + state.managedApps.options = mockManagedApps; + }); + + it('returns true', () => { + expect(showAdvancedFilters(state)).toBe(true); + }); + }); + + describe('when the managedApp does not support filters', () => { + beforeEach(() => { + state.environments.current = null; + state.environments.options = mockEnvironments; + state.managedApps.options = mockManagedApps; + state.managedApps.current = mockManagedApps[1].name; + }); + + it('returns false', () => { + expect(showAdvancedFilters(state)).toBe(false); + }); + }); }); diff --git a/spec/frontend/matchers.js b/spec/frontend/matchers.js index 53c6a72eea0..50feba86a61 100644 --- a/spec/frontend/matchers.js +++ b/spec/frontend/matchers.js @@ -9,8 +9,8 @@ export default { } const iconReferences = [].slice.apply(element.querySelectorAll('svg use')); - const matchingIcon = iconReferences.find(reference => - reference.getAttribute('xlink:href').endsWith(`#${iconName}`), + const matchingIcon = iconReferences.find( + reference => reference.parentNode.getAttribute('data-testid') === `${iconName}-icon`, ); const pass = Boolean(matchingIcon); @@ -22,7 +22,7 @@ export default { message = `${element.outerHTML} does not contain the sprite icon "${iconName}"!`; const existingIcons = iconReferences.map(reference => { - const iconUrl = reference.getAttribute('xlink:href'); + const iconUrl = reference.getAttribute('href'); return `"${iconUrl.replace(/^.+#/, '')}"`; }); if (existingIcons.length > 0) { diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js index 2265c9bdc2e..60d68aa5816 100644 --- a/spec/frontend/milestones/project_milestone_combobox_spec.js +++ b/spec/frontend/milestones/project_milestone_combobox_spec.js @@ -1,11 +1,13 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { ENTER_KEY } from '~/lib/utils/keys'; import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; import { milestones as projectMilestones } from './mock_data'; const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search'; +const TEST_SEARCH = 'TEST_SEARCH'; const extraLinks = [ { text: 'Create new', url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/new' }, @@ -21,6 +23,8 @@ describe('Milestone selector', () => { const findNoResultsMessage = () => wrapper.find({ ref: 'noResults' }); + const findSearchBox = () => wrapper.find(GlSearchBoxByType); + const factory = (options = {}) => { wrapper = shallowMount(MilestoneCombobox, { ...options, @@ -49,7 +53,7 @@ describe('Milestone selector', () => { }); it('renders the dropdown', () => { - expect(wrapper.find(GlNewDropdown)).toExist(); + expect(wrapper.find(GlDropdown)).toExist(); }); it('renders additional links', () => { @@ -63,7 +67,7 @@ describe('Milestone selector', () => { describe('before results', () => { it('should show a loading icon', () => { const request = mock.onGet(TEST_SEARCH_ENDPOINT, { - params: { search: 'TEST_SEARCH', scope: 'milestones' }, + params: { search: TEST_SEARCH, scope: 'milestones' }, }); expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); @@ -85,9 +89,9 @@ describe('Milestone selector', () => { describe('with empty results', () => { beforeEach(() => { mock - .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } }) + .onGet(TEST_SEARCH_ENDPOINT, { params: { search: TEST_SEARCH, scope: 'milestones' } }) .reply(200, []); - wrapper.find(GlSearchBoxByType).vm.$emit('input', 'TEST_SEARCH'); + findSearchBox().vm.$emit('input', TEST_SEARCH); return axios.waitForAll(); }); @@ -116,7 +120,7 @@ describe('Milestone selector', () => { web_url: 'http://127.0.0.1:3000/h5bp/html5-boilerplate/-/milestones/6', }, ]); - wrapper.find(GlSearchBoxByType).vm.$emit('input', 'v0.1'); + findSearchBox().vm.$emit('input', 'v0.1'); return axios.waitForAll().then(() => { items = wrapper.findAll('[role="milestone option"]'); }); @@ -147,4 +151,36 @@ describe('Milestone selector', () => { expect(findNoResultsMessage().exists()).toBe(false); }); }); + + describe('when Enter is pressed', () => { + beforeEach(() => { + factory({ + propsData: { + projectId, + preselectedMilestones, + extraLinks, + }, + data() { + return { + searchQuery: 'TEST_SEARCH', + }; + }, + }); + + mock + .onGet(TEST_SEARCH_ENDPOINT, { params: { search: 'TEST_SEARCH', scope: 'milestones' } }) + .reply(200, []); + }); + + it('should trigger a search', async () => { + mock.resetHistory(); + + findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + await axios.waitForAll(); + + expect(mock.history.get.length).toBe(1); + expect(mock.history.get[0].url).toBe(TEST_SEARCH_ENDPOINT); + }); + }); }); diff --git a/spec/frontend/mocks/mocks_helper.js b/spec/frontend/mocks/mocks_helper.js index 823ab41a5ba..0aa80331434 100644 --- a/spec/frontend/mocks/mocks_helper.js +++ b/spec/frontend/mocks/mocks_helper.js @@ -29,7 +29,6 @@ const getMockFiles = root => readdir.sync(root, { deep: MAX_DEPTH, filter: mockF const defaultSetMock = (srcPath, mockPath) => jest.mock(srcPath, () => jest.requireActual(mockPath)); -// eslint-disable-next-line import/prefer-default-export export const setupManualMocks = function setupManualMocks(setMock = defaultSetMock) { prefixMap.forEach(({ mocksRoot, requirePrefix }) => { const mocksRootAbsolute = path.join(__dirname, mocksRoot); diff --git a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap index 59c17daacff..2a8ce1d3f30 100644 --- a/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap +++ b/spec/frontend/monitoring/__snapshots__/alert_widget_spec.js.snap @@ -13,7 +13,7 @@ exports[`AlertWidget Alert firing displays a warning icon and matches snapshot 1 /> <span - class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me" + class="text-truncate gl-pl-2" > Firing: alert-label > 42 @@ -35,7 +35,7 @@ exports[`AlertWidget Alert not firing displays a warning icon and matches snapsh /> <span - class="text-truncate gl-pl-1-deprecated-no-really-do-not-use-me" + class="text-truncate gl-pl-2" > alert-label > 42 </span> diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js index 193dbb3e63f..d004b1da0b6 100644 --- a/spec/frontend/monitoring/alert_widget_spec.js +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -84,7 +84,7 @@ describe('AlertWidget', () => { }, }); }; - const hasLoadingIcon = () => wrapper.contains(GlLoadingIcon); + const hasLoadingIcon = () => wrapper.find(GlLoadingIcon).exists(); const findWidgetForm = () => wrapper.find({ ref: 'widgetForm' }); const findAlertErrorMessage = () => wrapper.find({ ref: 'alertErrorMessage' }); const findCurrentSettingsText = () => diff --git a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap index 7ef956f8e05..a28ecac00fd 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -32,7 +32,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="mb-2 pr-2 d-flex d-sm-block" > - <gl-new-dropdown-stub + <gl-dropdown-stub category="tertiary" class="flex-grow-1" data-qa-selector="environments_dropdown" @@ -47,12 +47,12 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="d-flex flex-column overflow-hidden" > - <gl-new-dropdown-header-stub> + <gl-dropdown-section-header-stub> Environment - </gl-new-dropdown-header-stub> + </gl-dropdown-section-header-stub> <gl-search-box-by-type-stub - class="m-2" + class="gl-m-3" clearbuttontitle="Clear" value="" /> @@ -69,7 +69,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` </div> </div> - </gl-new-dropdown-stub> + </gl-dropdown-stub> </div> <div diff --git a/spec/frontend/monitoring/components/charts/anomaly_spec.js b/spec/frontend/monitoring/components/charts/anomaly_spec.js index 15a52d03bcd..ebb49a2a0aa 100644 --- a/spec/frontend/monitoring/components/charts/anomaly_spec.js +++ b/spec/frontend/monitoring/components/charts/anomaly_spec.js @@ -46,9 +46,8 @@ describe('Anomaly chart component', () => { }); }); - it('is a Vue instance', () => { + it('renders correctly', () => { expect(findTimeSeries().exists()).toBe(true); - expect(findTimeSeries().isVueInstance()).toBe(true); }); describe('receives props correctly', () => { diff --git a/spec/frontend/monitoring/components/charts/bar_spec.js b/spec/frontend/monitoring/components/charts/bar_spec.js index e39e6e7e2c2..a363fafdc31 100644 --- a/spec/frontend/monitoring/components/charts/bar_spec.js +++ b/spec/frontend/monitoring/components/charts/bar_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlBarChart } from '@gitlab/ui/dist/charts'; import Bar from '~/monitoring/components/charts/bar.vue'; -import { barMockData } from '../../mock_data'; +import { barGraphData } from '../../graph_data'; jest.mock('~/lib/utils/icon_utils', () => ({ getSvgIconPathContent: jest.fn().mockResolvedValue('mockSvgPathContent'), @@ -10,11 +10,14 @@ jest.mock('~/lib/utils/icon_utils', () => ({ describe('Bar component', () => { let barChart; let store; + let graphData; beforeEach(() => { + graphData = barGraphData(); + barChart = shallowMount(Bar, { propsData: { - graphData: barMockData, + graphData, }, store, }); @@ -31,15 +34,11 @@ describe('Bar component', () => { beforeEach(() => { glbarChart = barChart.find(GlBarChart); - chartData = barChart.vm.chartData[barMockData.metrics[0].label]; - }); - - it('is a Vue instance', () => { - expect(glbarChart.isVueInstance()).toBe(true); + chartData = barChart.vm.chartData[graphData.metrics[0].label]; }); it('should display a label on the x axis', () => { - expect(glbarChart.vm.xAxisTitle).toBe(barMockData.xLabel); + expect(glbarChart.props('xAxisTitle')).toBe(graphData.xLabel); }); it('should return chartData as array of arrays', () => { diff --git a/spec/frontend/monitoring/components/charts/column_spec.js b/spec/frontend/monitoring/components/charts/column_spec.js index a2056d96dcf..16e2080c000 100644 --- a/spec/frontend/monitoring/components/charts/column_spec.js +++ b/spec/frontend/monitoring/components/charts/column_spec.js @@ -95,10 +95,6 @@ describe('Column component', () => { describe('wrapped components', () => { describe('GitLab UI column chart', () => { - it('is a Vue instance', () => { - expect(findChart().isVueInstance()).toBe(true); - }); - it('receives data properties needed for proper chart render', () => { expect(chartProps('data').values).toEqual(dataValues); }); diff --git a/spec/frontend/monitoring/components/charts/heatmap_spec.js b/spec/frontend/monitoring/components/charts/heatmap_spec.js index 27a2021e9be..c8375810a7b 100644 --- a/spec/frontend/monitoring/components/charts/heatmap_spec.js +++ b/spec/frontend/monitoring/components/charts/heatmap_spec.js @@ -24,21 +24,14 @@ describe('Heatmap component', () => { }; describe('wrapped chart', () => { - let glHeatmapChart; - beforeEach(() => { createWrapper(); - glHeatmapChart = findChart(); }); afterEach(() => { wrapper.destroy(); }); - it('is a Vue instance', () => { - expect(glHeatmapChart.isVueInstance()).toBe(true); - }); - it('should display a label on the x axis', () => { expect(wrapper.vm.xAxisName).toBe(graphData.xLabel); }); diff --git a/spec/frontend/monitoring/components/charts/stacked_column_spec.js b/spec/frontend/monitoring/components/charts/stacked_column_spec.js index bb2fbc68eaa..24a2af87eb8 100644 --- a/spec/frontend/monitoring/components/charts/stacked_column_spec.js +++ b/spec/frontend/monitoring/components/charts/stacked_column_spec.js @@ -3,13 +3,15 @@ import timezoneMock from 'timezone-mock'; import { cloneDeep } from 'lodash'; import { GlStackedColumnChart, GlChartLegend } from '@gitlab/ui/dist/charts'; import StackedColumnChart from '~/monitoring/components/charts/stacked_column.vue'; -import { stackedColumnMockedData } from '../../mock_data'; +import { stackedColumnGraphData } from '../../graph_data'; jest.mock('~/lib/utils/icon_utils', () => ({ getSvgIconPathContent: jest.fn().mockImplementation(icon => Promise.resolve(`${icon}-content`)), })); describe('Stacked column chart component', () => { + const stackedColumnMockedData = stackedColumnGraphData(); + let wrapper; const findChart = () => wrapper.find(GlStackedColumnChart); @@ -63,9 +65,9 @@ describe('Stacked column chart component', () => { const groupBy = findChart().props('groupBy'); expect(groupBy).toEqual([ - '2020-01-30T12:00:00.000Z', - '2020-01-30T12:01:00.000Z', - '2020-01-30T12:02:00.000Z', + '2015-07-01T20:10:50.000Z', + '2015-07-01T20:12:50.000Z', + '2015-07-01T20:14:50.000Z', ]); }); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 6f9a89feb3e..7f0ff534db3 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -632,9 +632,8 @@ describe('Time series component', () => { return wrapper.vm.$nextTick(); }); - it('is a Vue instance', () => { + it('exists', () => { expect(findChartComponent().exists()).toBe(true); - expect(findChartComponent().isVueInstance()).toBe(true); }); it('receives data properties needed for proper chart render', () => { diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js index 024b2cbd7f1..b22e05ec30a 100644 --- a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlNewDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import { createStore } from '~/monitoring/stores'; import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; import { setupAllDashboards, setupStoreWithData } from '../store_utils'; @@ -146,8 +146,8 @@ describe('Actions menu', () => { }); describe('add panel item', () => { - const GlNewDropdownItemStub = { - extends: GlNewDropdownItem, + const GlDropdownItemStub = { + extends: GlDropdownItem, props: { to: [String, Object], }, @@ -164,7 +164,7 @@ describe('Actions menu', () => { }, { mocks: { $route }, - stubs: { GlNewDropdownItem: GlNewDropdownItemStub }, + stubs: { GlDropdownItem: GlDropdownItemStub }, }, ); }); diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js index 5cf24706ebd..f9a7a4d5a93 100644 --- a/spec/frontend/monitoring/components/dashboard_header_spec.js +++ b/spec/frontend/monitoring/components/dashboard_header_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlNewDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui'; +import { GlDropdownItem, GlSearchBoxByType, GlLoadingIcon, GlButton } from '@gitlab/ui'; import { createStore } from '~/monitoring/stores'; import * as types from '~/monitoring/stores/mutation_types'; import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; @@ -31,7 +31,7 @@ describe('Dashboard header', () => { const findDashboardDropdown = () => wrapper.find(DashboardsDropdown); const findEnvsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' }); - const findEnvsDropdownItems = () => findEnvsDropdown().findAll(GlNewDropdownItem); + const findEnvsDropdownItems = () => findEnvsDropdown().findAll(GlDropdownItem); const findEnvsDropdownSearch = () => findEnvsDropdown().find(GlSearchBoxByType); const findEnvsDropdownSearchMsg = () => wrapper.find({ ref: 'monitorEnvironmentsDropdownMsg' }); const findEnvsDropdownLoadingIcon = () => findEnvsDropdown().find(GlLoadingIcon); diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js index 587ddd23d3f..08c69701bd2 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -68,7 +68,7 @@ describe('dashboard invalid url parameters', () => { it('form exists and can be submitted', () => { expect(findForm().exists()).toBe(true); expect(findSubmitBtn().exists()).toBe(true); - expect(findSubmitBtn().is('[disabled]')).toBe(false); + expect(findSubmitBtn().props('disabled')).toBe(false); }); it('form has a text area with a default value', () => { @@ -109,7 +109,7 @@ describe('dashboard invalid url parameters', () => { }); it('submit button is disabled', () => { - expect(findSubmitBtn().is('[disabled]')).toBe(true); + expect(findSubmitBtn().props('disabled')).toBe(true); }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index fb96bcc042f..8947a6c1570 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -2,7 +2,7 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import { setTestTimeout } from 'helpers/timeout'; -import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui'; +import { GlDropdownItem } from '@gitlab/ui'; import invalidUrl from '~/lib/utils/invalid_url'; import axios from '~/lib/utils/axios_utils'; import AlertWidget from '~/monitoring/components/alert_widget.vue'; @@ -15,10 +15,14 @@ import { mockNamespace, mockNamespacedData, mockTimeRange, - barMockData, } from '../mock_data'; import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data'; -import { anomalyGraphData, singleStatGraphData, heatmapGraphData } from '../graph_data'; +import { + anomalyGraphData, + singleStatGraphData, + heatmapGraphData, + barGraphData, +} from '../graph_data'; import { panelTypes } from '~/monitoring/constants'; @@ -137,7 +141,6 @@ describe('Dashboard Panel', () => { it('The Empty Chart component is rendered and is a Vue instance', () => { expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); - expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); }); @@ -166,7 +169,6 @@ describe('Dashboard Panel', () => { it('The Empty Chart component is rendered and is a Vue instance', () => { expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); - expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); }); @@ -222,13 +224,11 @@ describe('Dashboard Panel', () => { it('empty chart is rendered for empty results', () => { createWrapper({ graphData: graphDataEmpty }); expect(wrapper.find(MonitorEmptyChart).exists()).toBe(true); - expect(wrapper.find(MonitorEmptyChart).isVueInstance()).toBe(true); }); it('area chart is rendered by default', () => { createWrapper(); expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true); - expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); }); describe.each` @@ -240,7 +240,7 @@ describe('Dashboard Panel', () => { ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false} ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false} ${heatmapGraphData()} | ${MonitorHeatmapChart} | ${false} - ${barMockData} | ${MonitorBarChart} | ${false} + ${barGraphData()} | ${MonitorBarChart} | ${false} `('when $data.type data is provided', ({ data, component, hasCtxMenu }) => { const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; @@ -250,7 +250,6 @@ describe('Dashboard Panel', () => { it(`renders the chart component and binds attributes`, () => { expect(wrapper.find(component).exists()).toBe(true); - expect(wrapper.find(component).isVueInstance()).toBe(true); expect(wrapper.find(component).attributes()).toMatchObject(attrs); }); @@ -544,7 +543,6 @@ describe('Dashboard Panel', () => { }); it('it renders a time series chart with no errors', () => { - expect(wrapper.find(MonitorTimeSeriesChart).isVueInstance()).toBe(true); expect(wrapper.find(MonitorTimeSeriesChart).exists()).toBe(true); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index f37d95317ab..b7a0ea46b61 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -645,7 +645,7 @@ describe('Dashboard', () => { it('it enables draggables', () => { expect(findRearrangeButton().attributes('pressed')).toBeTruthy(); - expect(findEnabledDraggables()).toEqual(findDraggables()); + expect(findEnabledDraggables().wrappers).toEqual(findDraggables().wrappers); }); it('metrics can be swapped', () => { @@ -668,7 +668,11 @@ describe('Dashboard', () => { }); it('shows a remove button, which removes a panel', () => { - expect(findFirstDraggableRemoveButton().isEmpty()).toBe(false); + expect( + findFirstDraggableRemoveButton() + .find('a') + .exists(), + ).toBe(true); expect(findDraggablePanels().length).toEqual(metricsDashboardPanelCount); findFirstDraggableRemoveButton().trigger('click'); @@ -703,8 +707,7 @@ describe('Dashboard', () => { }); it('renders correctly', () => { - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.exists()).toBe(true); + expect(wrapper.html()).not.toBe(''); }); }); diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index 89adbad386f..ef5784183b2 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlNewDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDropdownItem, GlIcon } from '@gitlab/ui'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; @@ -33,8 +33,8 @@ describe('DashboardsDropdown', () => { }); } - const findItems = () => wrapper.findAll(GlNewDropdownItem); - const findItemAt = i => wrapper.findAll(GlNewDropdownItem).at(i); + const findItems = () => wrapper.findAll(GlDropdownItem); + const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i); const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); diff --git a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js index 29e4c4514fe..29115ffb817 100644 --- a/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js +++ b/spec/frontend/monitoring/components/duplicate_dashboard_form_spec.js @@ -50,7 +50,7 @@ describe('DuplicateDashboardForm', () => { it('when is empty', () => { setValue('fileName', ''); return wrapper.vm.$nextTick(() => { - expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true); + expect(findByRef('fileNameFormGroup').classes()).toContain('is-valid'); expect(findInvalidFeedback().exists()).toBe(false); }); }); @@ -58,7 +58,7 @@ describe('DuplicateDashboardForm', () => { it('when is valid', () => { setValue('fileName', 'my_dashboard.yml'); return wrapper.vm.$nextTick(() => { - expect(findByRef('fileNameFormGroup').is('.is-valid')).toBe(true); + expect(findByRef('fileNameFormGroup').classes()).toContain('is-valid'); expect(findInvalidFeedback().exists()).toBe(false); }); }); @@ -66,7 +66,7 @@ describe('DuplicateDashboardForm', () => { it('when is not valid', () => { setValue('fileName', 'my_dashboard.exe'); return wrapper.vm.$nextTick(() => { - expect(findByRef('fileNameFormGroup').is('.is-invalid')).toBe(true); + expect(findByRef('fileNameFormGroup').classes()).toContain('is-invalid'); expect(findInvalidFeedback().text()).toBeTruthy(); }); }); @@ -144,7 +144,7 @@ describe('DuplicateDashboardForm', () => { return wrapper.vm.$nextTick().then(() => { wrapper.find('form').trigger('change'); - expect(findByRef('branchName').is(':focus')).toBe(true); + expect(document.activeElement).toBe(findByRef('branchName').element); }); }); }); diff --git a/spec/frontend/monitoring/components/embeds/embed_group_spec.js b/spec/frontend/monitoring/components/embeds/embed_group_spec.js index 49c10483c45..b63995ec2d4 100644 --- a/spec/frontend/monitoring/components/embeds/embed_group_spec.js +++ b/spec/frontend/monitoring/components/embeds/embed_group_spec.js @@ -1,6 +1,6 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlDeprecatedButton, GlCard } from '@gitlab/ui'; +import { GlButton, GlCard } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import EmbedGroup from '~/monitoring/components/embeds/embed_group.vue'; import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; @@ -71,16 +71,16 @@ describe('Embed Group', () => { it('is expanded by default', () => { metricsWithDataGetter.mockReturnValue([1]); - mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } }); + mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); expect(wrapper.find('.card-body').classes()).not.toContain('d-none'); }); it('collapses when clicked', done => { metricsWithDataGetter.mockReturnValue([1]); - mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } }); + mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - wrapper.find(GlDeprecatedButton).trigger('click'); + wrapper.find(GlButton).trigger('click'); wrapper.vm.$nextTick(() => { expect(wrapper.find('.card-body').classes()).toContain('d-none'); @@ -148,16 +148,16 @@ describe('Embed Group', () => { describe('button text', () => { it('has a singular label when there is one embed', () => { metricsWithDataGetter.mockReturnValue([1]); - mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } }); + mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - expect(wrapper.find(GlDeprecatedButton).text()).toBe('Hide chart'); + expect(wrapper.find(GlButton).text()).toBe('Hide chart'); }); it('has a plural label when there are multiple embeds', () => { metricsWithDataGetter.mockReturnValue([2]); - mountComponent({ shallow: false, stubs: { MetricEmbed: '<div />' } }); + mountComponent({ shallow: false, stubs: { MetricEmbed: true } }); - expect(wrapper.find(GlDeprecatedButton).text()).toBe('Hide charts'); + expect(wrapper.find(GlButton).text()).toBe('Hide charts'); }); }); }); diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js index 86e2523f708..ebcd6c0df3a 100644 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -50,7 +50,7 @@ describe('Graph group component', () => { it('should contain a tab index for the collapse button', () => { const groupToggle = findToggleButton(); - expect(groupToggle.is('[tabindex]')).toBe(true); + expect(groupToggle.attributes('tabindex')).toBeDefined(); }); it('should show the open the group when collapseGroup is set to true', () => { diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js index a9b8295f38e..8a478362b5e 100644 --- a/spec/frontend/monitoring/components/refresh_button_spec.js +++ b/spec/frontend/monitoring/components/refresh_button_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import Visibility from 'visibilityjs'; -import { GlNewDropdown, GlNewDropdownItem, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownItem, GlButton } from '@gitlab/ui'; import { createStore } from '~/monitoring/stores'; import RefreshButton from '~/monitoring/components/refresh_button.vue'; @@ -15,8 +15,8 @@ describe('RefreshButton', () => { }; const findRefreshBtn = () => wrapper.find(GlButton); - const findDropdown = () => wrapper.find(GlNewDropdown); - const findOptions = () => findDropdown().findAll(GlNewDropdownItem); + const findDropdown = () => wrapper.find(GlDropdown); + const findOptions = () => findDropdown().findAll(GlDropdownItem); const findOptionAt = index => findOptions().at(index); const expectFetchDataToHaveBeenCalledTimes = times => { diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index 30040d3f89f..18ec74550b4 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -1,8 +1,7 @@ import { stateAndPropsFromDataset } from '~/monitoring/utils'; import { mapToDashboardViewModel } from '~/monitoring/stores/utils'; import { metricStates } from '~/monitoring/constants'; -import { convertObjectProps } from '~/lib/utils/common_utils'; -import { convertToCamelCase } from '~/lib/utils/text_utility'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { metricsResult } from './mock_data'; @@ -14,13 +13,7 @@ export const metricsDashboardResponse = getJSONFixture( export const metricsDashboardPayload = metricsDashboardResponse.dashboard; const datasetState = stateAndPropsFromDataset( - // It's preferable to have props in snake_case, this will be addressed at: - // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/33574 - convertObjectProps( - // Some props use kebab-case, convert to snake_case first - key => convertToCamelCase(key.replace(/-/g, '_')), - metricsDashboardResponse.metrics_data, - ), + convertObjectPropsToCamelCase(metricsDashboardResponse.metrics_data), ); // new properties like addDashboardDocumentationPath prop and alertsEndpoint diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js index f85351e55d7..494fdb1b159 100644 --- a/spec/frontend/monitoring/graph_data.js +++ b/spec/frontend/monitoring/graph_data.js @@ -246,3 +246,29 @@ export const gaugeChartGraphData = (panelOptions = {}) => { ], }); }; + +/** + * Generates stacked mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + */ +export const stackedColumnGraphData = (panelOptions = {}, dataOptions = {}) => { + return { + ...timeSeriesGraphData(panelOptions, dataOptions), + type: panelTypes.STACKED_COLUMN, + }; +}; + +/** + * Generates bar mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * @param {Object} dataOptions + */ +export const barGraphData = (panelOptions = {}, dataOptions = {}) => { + return { + ...timeSeriesGraphData(panelOptions, dataOptions), + type: panelTypes.BAR, + }; +}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 28a7dd1af4f..aea8815fb10 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -245,51 +245,6 @@ export const metricsResult = [ }, ]; -export const stackedColumnMockedData = { - title: 'memories', - type: 'stacked-column', - x_label: 'x label', - y_label: 'y label', - metrics: [ - { - label: 'memory_1024', - unit: 'count', - series_name: 'group 1', - prometheus_endpoint_path: - '/root/autodevops-deploy-6/-/environments/24/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', - metricId: 'NO_DB_metric_of_ages_1024', - result: [ - { - metric: {}, - values: [ - ['2020-01-30T12:00:00.000Z', '5'], - ['2020-01-30T12:01:00.000Z', '10'], - ['2020-01-30T12:02:00.000Z', '15'], - ], - }, - ], - }, - { - label: 'memory_1000', - unit: 'count', - series_name: 'group 2', - prometheus_endpoint_path: - '/root/autodevops-deploy-6/-/environments/24/prometheus/api/v1/query_range?query=avg%28sum%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+by+%28job%29%29+without+%28job%29+%2F+count%28avg%28container_memory_usage_bytes%7Bcontainer_name%21%3D%22POD%22%2Cpod_name%3D~%22%5E%25%7Bci_environment_slug%7D-%28%5B%5Ec%5D.%2A%7Cc%28%5B%5Ea%5D%7Ca%28%5B%5En%5D%7Cn%28%5B%5Ea%5D%7Ca%28%5B%5Er%5D%7Cr%5B%5Ey%5D%29%29%29%29.%2A%7C%29-%28.%2A%29%22%2Cnamespace%3D%22%25%7Bkube_namespace%7D%22%7D%29+without+%28job%29%29+%2F1024%2F1024', - metricId: 'NO_DB_metric_of_ages_1000', - result: [ - { - metric: {}, - values: [ - ['2020-01-30T12:00:00.000Z', '20'], - ['2020-01-30T12:01:00.000Z', '25'], - ['2020-01-30T12:02:00.000Z', '30'], - ], - }, - ], - }, - ], -}; - export const barMockData = { title: 'SLA Trends - Primary Services', type: 'bar', diff --git a/spec/frontend/mr_popover/mr_popover_spec.js b/spec/frontend/mr_popover/mr_popover_spec.js index 3f62dca4a57..094d1a6472c 100644 --- a/spec/frontend/mr_popover/mr_popover_spec.js +++ b/spec/frontend/mr_popover/mr_popover_spec.js @@ -61,7 +61,7 @@ describe('MR Popover', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.contains(CiIcon)).toBe(false); + expect(wrapper.find(CiIcon).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/namespace_select_spec.js b/spec/frontend/namespace_select_spec.js index 399fa950769..d6f3eb75cd9 100644 --- a/spec/frontend/namespace_select_spec.js +++ b/spec/frontend/namespace_select_spec.js @@ -1,56 +1,55 @@ -import $ from 'jquery'; import NamespaceSelect from '~/namespace_select'; +import initDeprecatedJQueryDropdown from '~/deprecated_jquery_dropdown'; -describe('NamespaceSelect', () => { - beforeEach(() => { - jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {}); - }); +jest.mock('~/deprecated_jquery_dropdown'); - it('initializes glDropdown', () => { +describe('NamespaceSelect', () => { + it('initializes deprecatedJQueryDropdown', () => { const dropdown = document.createElement('div'); // eslint-disable-next-line no-new new NamespaceSelect({ dropdown }); - expect($.fn.glDropdown).toHaveBeenCalled(); + expect(initDeprecatedJQueryDropdown).toHaveBeenCalled(); }); describe('as input', () => { - let glDropdownOptions; + let deprecatedJQueryDropdownOptions; beforeEach(() => { const dropdown = document.createElement('div'); // eslint-disable-next-line no-new new NamespaceSelect({ dropdown }); - [[glDropdownOptions]] = $.fn.glDropdown.mock.calls; + [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; }); it('prevents click events', () => { const dummyEvent = new Event('dummy'); jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - glDropdownOptions.clicked({ e: dummyEvent }); + // expect(foo).toContain('test'); + deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); expect(dummyEvent.preventDefault).toHaveBeenCalled(); }); }); describe('as filter', () => { - let glDropdownOptions; + let deprecatedJQueryDropdownOptions; beforeEach(() => { const dropdown = document.createElement('div'); dropdown.dataset.isFilter = 'true'; // eslint-disable-next-line no-new new NamespaceSelect({ dropdown }); - [[glDropdownOptions]] = $.fn.glDropdown.mock.calls; + [[, deprecatedJQueryDropdownOptions]] = initDeprecatedJQueryDropdown.mock.calls; }); it('does not prevent click events', () => { const dummyEvent = new Event('dummy'); jest.spyOn(dummyEvent, 'preventDefault').mockImplementation(() => {}); - glDropdownOptions.clicked({ e: dummyEvent }); + deprecatedJQueryDropdownOptions.clicked({ e: dummyEvent }); expect(dummyEvent.preventDefault).not.toHaveBeenCalled(); }); @@ -58,7 +57,7 @@ describe('NamespaceSelect', () => { it('sets URL of dropdown items', () => { const dummyNamespace = { id: 'eal' }; - const itemUrl = glDropdownOptions.url(dummyNamespace); + const itemUrl = deprecatedJQueryDropdownOptions.url(dummyNamespace); expect(itemUrl).toContain(`namespace_id=${dummyNamespace.id}`); }); diff --git a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap index b1a718d58b5..13af29821d8 100644 --- a/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap +++ b/spec/frontend/notes/components/__snapshots__/discussion_jump_to_next_button_spec.js.snap @@ -12,7 +12,7 @@ exports[`JumpToNextDiscussionButton matches the snapshot 1`] = ` data-track-property="click_next_unresolved_thread" title="Jump to next unresolved thread" > - <icon-stub + <gl-icon-stub name="comment-next" size="16" /> diff --git a/spec/frontend/notes/components/comment_form_spec.js b/spec/frontend/notes/components/comment_form_spec.js index dc68c4371aa..59fa7b372ed 100644 --- a/spec/frontend/notes/components/comment_form_spec.js +++ b/spec/frontend/notes/components/comment_form_spec.js @@ -267,15 +267,14 @@ describe('issue_comment_form component', () => { }); describe('when clicking close/reopen button', () => { - it('should disable button and show a loading spinner', done => { + it('should disable button and show a loading spinner', () => { const toggleStateButton = wrapper.find('.js-action-button'); toggleStateButton.trigger('click'); - wrapper.vm.$nextTick(() => { - expect(toggleStateButton.element.disabled).toEqual(true); - expect(toggleStateButton.find('.js-loading-button-icon').exists()).toBe(true); - done(); + return wrapper.vm.$nextTick().then(() => { + expect(toggleStateButton.element.disabled).toEqual(true); + expect(toggleStateButton.props('loading')).toBe(true); }); }); }); @@ -321,4 +320,33 @@ describe('issue_comment_form component', () => { expect(wrapper.find('textarea').exists()).toBe(false); }); }); + + describe('when issuable is open', () => { + beforeEach(() => { + setupStore(userDataMock, noteableDataMock); + }); + + it.each([['opened', 'warning'], ['reopened', 'warning']])( + 'when %i, it changes the variant of the btn to %i', + (a, expected) => { + store.state.noteableData.state = a; + + mountComponent(); + + expect(wrapper.find('.js-action-button').props('variant')).toBe(expected); + }, + ); + }); + + describe('when issuable is not open', () => { + beforeEach(() => { + setupStore(userDataMock, noteableDataMock); + + mountComponent(); + }); + + it('should render the "default" variant of the button', () => { + expect(wrapper.find('.js-action-button').props('variant')).toBe('warning'); + }); + }); }); diff --git a/spec/frontend/notes/components/discussion_counter_spec.js b/spec/frontend/notes/components/discussion_counter_spec.js index 04535aa17c5..affd6c1d1d2 100644 --- a/spec/frontend/notes/components/discussion_counter_spec.js +++ b/spec/frontend/notes/components/discussion_counter_spec.js @@ -1,5 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import notesModule from '~/notes/stores/modules'; import DiscussionCounter from '~/notes/components/discussion_counter.vue'; import { noteableDataMock, discussionMock, notesDataMock, userDataMock } from '../mock_data'; @@ -112,13 +113,13 @@ describe('DiscussionCounter component', () => { updateStoreWithExpanded(true); expect(wrapper.vm.allExpanded).toBe(true); - expect(toggleAllButton.find({ name: 'angle-up' }).exists()).toBe(true); + expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up'); toggleAllButton.trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.allExpanded).toBe(false); - expect(toggleAllButton.find({ name: 'angle-down' }).exists()).toBe(true); + expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down'); }); }); @@ -126,13 +127,13 @@ describe('DiscussionCounter component', () => { updateStoreWithExpanded(false); expect(wrapper.vm.allExpanded).toBe(false); - expect(toggleAllButton.find({ name: 'angle-down' }).exists()).toBe(true); + expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-down'); toggleAllButton.trigger('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.allExpanded).toBe(true); - expect(toggleAllButton.find({ name: 'angle-up' }).exists()).toBe(true); + expect(toggleAllButton.find(GlIcon).props().name).toBe('angle-up'); }); }); }); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index 9a7896475e6..91ff796b9de 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -150,7 +150,7 @@ describe('DiscussionFilter component', () => { eventHub.$emit('MergeRequestTabChange', 'commit'); wrapper.vm.$nextTick(() => { - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); done(); }); }); diff --git a/spec/frontend/notes/components/discussion_resolve_button_spec.js b/spec/frontend/notes/components/discussion_resolve_button_spec.js index c64e299efc3..41701e54dfa 100644 --- a/spec/frontend/notes/components/discussion_resolve_button_spec.js +++ b/spec/frontend/notes/components/discussion_resolve_button_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import resolveDiscussionButton from '~/notes/components/discussion_resolve_button.vue'; const buttonTitle = 'Resolve discussion'; @@ -26,9 +27,9 @@ describe('resolveDiscussionButton', () => { }); it('should emit a onClick event on button click', () => { - const button = wrapper.find({ ref: 'button' }); + const button = wrapper.find(GlButton); - button.trigger('click'); + button.vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.emitted()).toEqual({ @@ -38,7 +39,7 @@ describe('resolveDiscussionButton', () => { }); it('should contain the provided button title', () => { - const button = wrapper.find({ ref: 'button' }); + const button = wrapper.find(GlButton); expect(button.text()).toContain(buttonTitle); }); @@ -51,9 +52,9 @@ describe('resolveDiscussionButton', () => { }, }); - const button = wrapper.find({ ref: 'isResolvingIcon' }); + const button = wrapper.find(GlButton); - expect(button.exists()).toEqual(true); + expect(button.props('loading')).toEqual(true); }); it('should only show a loading spinner while resolving', () => { @@ -64,10 +65,10 @@ describe('resolveDiscussionButton', () => { }, }); - const button = wrapper.find({ ref: 'isResolvingIcon' }); + const button = wrapper.find(GlButton); wrapper.vm.$nextTick(() => { - expect(button.exists()).toEqual(false); + expect(button.props('loading')).toEqual(false); }); }); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 97d1752726b..a79c3bbacb7 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -35,8 +35,12 @@ describe('noteActions', () => { canEdit: true, canAwardEmoji: true, canReportAsAbuse: true, + isAuthor: true, + isContributor: false, + noteableType: 'MergeRequest', noteId: '539', noteUrl: `${TEST_HOST}/group/project/-/merge_requests/1#note_1`, + projectName: 'project', reportAbusePath: `${TEST_HOST}/abuse_reports/new?ref_url=http%3A%2F%2Flocalhost%3A3000%2Fgitlab-org%2Fgitlab-ce%2Fissues%2F7%23note_539&user_id=26`, showReply: false, }; @@ -60,15 +64,43 @@ describe('noteActions', () => { wrapper = shallowMountNoteActions(props); }); + it('should render noteable author badge', () => { + expect( + wrapper + .findAll('.note-role') + .at(0) + .text() + .trim(), + ).toEqual('Author'); + }); + it('should render access level badge', () => { expect( wrapper - .find('.note-role') + .findAll('.note-role') + .at(1) .text() .trim(), ).toEqual(props.accessLevel); }); + it('should render contributor badge', () => { + wrapper.setProps({ + accessLevel: null, + isContributor: true, + }); + + return wrapper.vm.$nextTick().then(() => { + expect( + wrapper + .findAll('.note-role') + .at(1) + .text() + .trim(), + ).toBe('Contributor'); + }); + }); + it('should render emoji link', () => { expect(wrapper.find('.js-add-award').exists()).toBe(true); expect(wrapper.find('.js-add-award').attributes('data-position')).toBe('right'); diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index fbfba2efb1d..c6034639a4a 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -330,6 +330,8 @@ describe('note_app', () => { wrapper.vm.$parent.$el.dispatchEvent(toggleAwardEvent); + jest.advanceTimersByTime(2); + expect(toggleAwardAction).toHaveBeenCalledTimes(1); const [, payload] = toggleAwardAction.mock.calls[0]; diff --git a/spec/frontend/notes/helpers.js b/spec/frontend/notes/helpers.js index 3f349b40ba5..c8168a49a5b 100644 --- a/spec/frontend/notes/helpers.js +++ b/spec/frontend/notes/helpers.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const resetStore = store => { store.replaceState({ notes: [], diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index 11c0bbfefc9..d203435e7bf 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -194,13 +194,9 @@ describe('Discussion navigation mixin', () => { }); it('expands discussion', () => { - expect(expandDiscussion).toHaveBeenCalledWith( - expect.anything(), - { - discussionId: expected, - }, - undefined, - ); + expect(expandDiscussion).toHaveBeenCalledWith(expect.anything(), { + discussionId: expected, + }); }); it('scrolls to discussion', () => { diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 6b8d0790669..4681f3aa429 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -3,6 +3,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import Api from '~/api'; import { deprecatedCreateFlash as Flash } from '~/flash'; import * as actions from '~/notes/stores/actions'; +import mutations from '~/notes/stores/mutations'; import * as mutationTypes from '~/notes/stores/mutation_types'; import * as notesConstants from '~/notes/constants'; import createStore from '~/notes/stores'; @@ -334,6 +335,9 @@ describe('Actions Notes Store', () => { it('calls service with last fetched state', done => { store .dispatch('poll') + .then(() => { + jest.advanceTimersByTime(2); + }) .then(() => new Promise(resolve => requestAnimationFrame(resolve))) .then(() => { expect(store.state.lastFetchedAt).toBe('123456'); @@ -651,6 +655,26 @@ describe('Actions Notes Store', () => { }); describe('updateOrCreateNotes', () => { + it('Prevents `fetchDiscussions` being called multiple times within time limit', () => { + jest.useFakeTimers(); + const note = { id: 1234, type: notesConstants.DIFF_NOTE }; + const getters = { notesById: {} }; + state = { discussions: [note], notesData: { discussionsPath: '' } }; + commit.mockImplementation((type, value) => { + if (type === mutationTypes.SET_FETCHING_DISCUSSIONS) { + mutations[type](state, value); + } + }); + + actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); + actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); + + jest.runAllTimers(); + actions.updateOrCreateNotes({ commit, state, getters, dispatch }, [note]); + + expect(dispatch).toHaveBeenCalledTimes(2); + }); + it('Updates existing note', () => { const note = { id: 1234 }; const getters = { notesById: { 1234: note } }; diff --git a/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap deleted file mode 100644 index 172b8919673..00000000000 --- a/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap +++ /dev/null @@ -1,46 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Package code instruction multiline to match the snapshot 1`] = ` -<div> - <pre - class="js-instruction-pre" - > - this is some -multiline text - </pre> -</div> -`; - -exports[`Package code instruction single line to match the default snapshot 1`] = ` -<div - class="input-group gl-mb-3" -> - <input - class="form-control monospace js-instruction-input" - readonly="readonly" - type="text" - /> - - <span - class="input-group-append js-instruction-button" - > - <button - class="btn input-group-text btn-secondary btn-md btn-default" - data-clipboard-text="npm i @my-package" - title="Copy npm install command" - type="button" - > - <!----> - - <svg - class="gl-icon s16" - data-testid="copy-to-clipboard-icon" - > - <use - href="#copy-to-clipboard" - /> - </svg> - </button> - </span> -</div> -`; diff --git a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap index 852292e084b..a1d08f032bc 100644 --- a/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap @@ -8,18 +8,12 @@ exports[`ConanInstallation renders all the messages 1`] = ` Installation </h3> - <h4 - class="gl-font-base" - > - - Conan Command - - </h4> - <code-instruction-stub copytext="Copy Conan Command" instruction="foo/command" + label="Conan Command" trackingaction="copy_conan_command" + trackinglabel="code_instruction" /> <h3 @@ -28,18 +22,12 @@ exports[`ConanInstallation renders all the messages 1`] = ` Registry setup </h3> - <h4 - class="gl-font-base" - > - - Add Conan Remote - - </h4> - <code-instruction-stub copytext="Copy Conan Setup Command" instruction="foo/setup" + label="Add Conan Remote" trackingaction="copy_conan_setup_command" + trackinglabel="code_instruction" /> <gl-sprintf-stub diff --git a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap index 28b7ca442eb..39469bf4fd0 100644 --- a/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap @@ -21,7 +21,7 @@ exports[`DependencyRow renders full dependency 1`] = ` </div> <div - class="table-section section-50 gl-display-flex justify-content-md-end" + class="table-section section-50 gl-display-flex gl-md-justify-content-end" data-testid="version-pattern" > <span diff --git a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap index 10e54500797..6d22b372d41 100644 --- a/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap @@ -8,14 +8,6 @@ exports[`MavenInstallation renders all the messages 1`] = ` Installation </h3> - <h4 - class="gl-font-base" - > - - Maven XML - - </h4> - <p> <gl-sprintf-stub message="Copy and paste this inside your %{codeStart}pom.xml%{codeEnd} %{codeStart}dependencies%{codeEnd} block." @@ -25,22 +17,18 @@ exports[`MavenInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Maven XML" instruction="foo/xml" + label="Maven XML" multiline="true" trackingaction="copy_maven_xml" + trackinglabel="code_instruction" /> - <h4 - class="gl-font-base" - > - - Maven Command - - </h4> - <code-instruction-stub copytext="Copy Maven command" instruction="foo/command" + label="Maven Command" trackingaction="copy_maven_command" + trackinglabel="code_instruction" /> <h3 @@ -58,8 +46,10 @@ exports[`MavenInstallation renders all the messages 1`] = ` <code-instruction-stub copytext="Copy Maven registry XML" instruction="foo/setup" + label="" multiline="true" trackingaction="copy_maven_setup_xml" + trackinglabel="code_instruction" /> <gl-sprintf-stub diff --git a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap index 58a509e6847..b616751f75f 100644 --- a/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap @@ -8,28 +8,20 @@ exports[`NpmInstallation renders all the messages 1`] = ` Installation </h3> - <h4 - class="gl-font-base" - > - npm command - </h4> - <code-instruction-stub copytext="Copy npm command" instruction="npm i @Test/package" + label="npm command" trackingaction="copy_npm_install_command" + trackinglabel="code_instruction" /> - <h4 - class="gl-font-base" - > - yarn command - </h4> - <code-instruction-stub copytext="Copy yarn command" instruction="yarn add @Test/package" + label="yarn command" trackingaction="copy_yarn_install_command" + trackinglabel="code_instruction" /> <h3 @@ -38,28 +30,20 @@ exports[`NpmInstallation renders all the messages 1`] = ` Registry setup </h3> - <h4 - class="gl-font-base" - > - npm command - </h4> - <code-instruction-stub copytext="Copy npm setup command" - instruction="echo @Test:registry=undefined >> .npmrc" + instruction="echo @Test:registry=undefined/ >> .npmrc" + label="npm command" trackingaction="copy_npm_setup_command" + trackinglabel="code_instruction" /> - <h4 - class="gl-font-base" - > - yarn command - </h4> - <code-instruction-stub copytext="Copy yarn setup command" - instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined\\\\\\" >> .yarnrc" + instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined/\\\\\\" >> .yarnrc" + label="yarn command" trackingaction="copy_yarn_setup_command" + trackinglabel="code_instruction" /> <gl-sprintf-stub diff --git a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap index 67810290c62..84cf5e4db49 100644 --- a/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap @@ -8,18 +8,12 @@ exports[`NugetInstallation renders all the messages 1`] = ` Installation </h3> - <h4 - class="gl-font-base" - > - - NuGet Command - - </h4> - <code-instruction-stub copytext="Copy NuGet Command" instruction="foo/command" + label="NuGet Command" trackingaction="copy_nuget_install_command" + trackinglabel="code_instruction" /> <h3 @@ -28,18 +22,12 @@ exports[`NugetInstallation renders all the messages 1`] = ` Registry setup </h3> - <h4 - class="gl-font-base" - > - - Add NuGet Source - - </h4> - <code-instruction-stub copytext="Copy NuGet Setup Command" instruction="foo/setup" + label="Add NuGet Source" trackingaction="copy_nuget_setup_command" + trackinglabel="code_instruction" /> <gl-sprintf-stub diff --git a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap index bdcd4a9e077..4d9e0af1545 100644 --- a/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap @@ -2,171 +2,151 @@ exports[`PackageTitle renders with tags 1`] = ` <div - class="gl-flex-direction-column" + class="gl-display-flex gl-justify-content-space-between gl-py-3" + data-qa-selector="package_title" > <div - class="gl-display-flex" + class="gl-flex-direction-column" > - <!----> - <div - class="gl-display-flex gl-flex-direction-column" + class="gl-display-flex" > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - > - - Test package - - </h1> + <!----> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500" + class="gl-display-flex gl-flex-direction-column" > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> + <h1 + class="gl-font-size-h1 gl-mt-3 gl-mb-2" + data-testid="title" + > + Test package + </h1> - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <gl-sprintf-stub + message="v%{version} published %{timeAgo}" + /> + </div> </div> </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <gl-icon-stub - class="gl-text-gray-500 gl-mr-3" - name="package" - size="16" - /> - - <span - class="gl-font-weight-bold" - data-testid="package-type" - > - maven - </span> - </div> <div - class="gl-display-flex gl-align-items-center gl-mr-5" + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" > - <package-tags-stub - tagdisplaylimit="1" - tags="[object Object],[object Object],[object Object],[object Object]" - /> - </div> - - <!----> - - <!----> - - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <gl-icon-stub - class="gl-text-gray-500 gl-mr-3" - name="disk" - size="16" - /> - - <span - class="gl-font-weight-bold" - data-testid="package-size" + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-type" + icon="package" + link="" + size="s" + text="maven" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-size" + icon="disk" + link="" + size="s" + text="300 bytes" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" > - 300 bytes - </span> + <package-tags-stub + hidelabel="true" + tagdisplaylimit="2" + tags="[object Object],[object Object],[object Object],[object Object]" + /> + </div> </div> </div> + + <!----> </div> `; exports[`PackageTitle renders without tags 1`] = ` <div - class="gl-flex-direction-column" + class="gl-display-flex gl-justify-content-space-between gl-py-3" + data-qa-selector="package_title" > <div - class="gl-display-flex" + class="gl-flex-direction-column" > - <!----> - <div - class="gl-display-flex gl-flex-direction-column" + class="gl-display-flex" > - <h1 - class="gl-font-size-h1 gl-mt-3 gl-mb-2" - > - - Test package - - </h1> + <!----> <div - class="gl-display-flex gl-align-items-center gl-text-gray-500" + class="gl-display-flex gl-flex-direction-column" > - <gl-icon-stub - class="gl-mr-3" - name="eye" - size="16" - /> + <h1 + class="gl-font-size-h1 gl-mt-3 gl-mb-2" + data-testid="title" + > + Test package + </h1> - <gl-sprintf-stub - message="v%{version} published %{timeAgo}" - /> + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <gl-sprintf-stub + message="v%{version} published %{timeAgo}" + /> + </div> </div> </div> - </div> - - <div - class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mb-3" - > - <div - class="gl-display-flex gl-align-items-center gl-mr-5" - > - <gl-icon-stub - class="gl-text-gray-500 gl-mr-3" - name="package" - size="16" - /> - - <span - class="gl-font-weight-bold" - data-testid="package-type" - > - maven - </span> - </div> - - <!----> - - <!----> - - <!----> <div - class="gl-display-flex gl-align-items-center gl-mr-5" + class="gl-display-flex gl-flex-wrap gl-align-items-center gl-mt-3" > - <gl-icon-stub - class="gl-text-gray-500 gl-mr-3" - name="disk" - size="16" - /> - - <span - class="gl-font-weight-bold" - data-testid="package-size" + <div + class="gl-display-flex gl-align-items-center gl-mr-5" > - 300 bytes - </span> + <metadata-item-stub + data-testid="package-type" + icon="package" + link="" + size="s" + text="maven" + /> + </div> + <div + class="gl-display-flex gl-align-items-center gl-mr-5" + > + <metadata-item-stub + data-testid="package-size" + icon="disk" + link="" + size="s" + text="300 bytes" + /> + </div> </div> </div> + + <!----> </div> `; diff --git a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap index 5c1e74d73af..2a588f99c1d 100644 --- a/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap @@ -8,19 +8,13 @@ exports[`PypiInstallation renders all the messages 1`] = ` Installation </h3> - <h4 - class="gl-font-base" - > - - Pip Command - - </h4> - <code-instruction-stub copytext="Copy Pip command" data-testid="pip-command" instruction="pip install" + label="Pip Command" trackingaction="copy_pip_install_command" + trackinglabel="code_instruction" /> <h3 @@ -39,8 +33,10 @@ exports[`PypiInstallation renders all the messages 1`] = ` copytext="Copy .pypirc content" data-testid="pypi-setup-content" instruction="python setup" + label="" multiline="true" trackingaction="copy_pypi_setup_command" + trackinglabel="code_instruction" /> <gl-sprintf-stub diff --git a/spec/frontend/packages/details/components/additional_metadata_spec.js b/spec/frontend/packages/details/components/additional_metadata_spec.js index b2337b86740..111e4205abb 100644 --- a/spec/frontend/packages/details/components/additional_metadata_spec.js +++ b/spec/frontend/packages/details/components/additional_metadata_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink, GlSprintf } from '@gitlab/ui'; -import DetailsRow from '~/registry/shared/components/details_row.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; import component from '~/packages/details/components/additional_metadata.vue'; import { mavenPackage, conanPackage, nugetPackage, npmPackage } from '../../mock_data'; diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js index f535f3f5744..e82c74e56e5 100644 --- a/spec/frontend/packages/details/components/app_spec.js +++ b/spec/frontend/packages/details/components/app_spec.js @@ -34,12 +34,15 @@ describe('PackagesApp', () => { let wrapper; let store; const fetchPackageVersions = jest.fn(); + const deletePackage = jest.fn(); + const defaultProjectName = 'bar'; + const { location } = window; function createComponent({ packageEntity = mavenPackage, packageFiles = mavenFiles, isLoading = false, - oneColumnView = false, + projectName = defaultProjectName, } = {}) { store = new Vuex.Store({ state: { @@ -47,14 +50,15 @@ describe('PackagesApp', () => { packageEntity, packageFiles, canDelete: true, - destroyPath: 'destroy-package-path', emptySvgPath: 'empty-illustration', npmPath: 'foo', npmHelpPath: 'foo', - projectName: 'bar', - oneColumnView, + projectName, + projectListUrl: 'project_url', + groupListUrl: 'group_url', }, actions: { + deletePackage, fetchPackageVersions, }, getters, @@ -65,6 +69,8 @@ describe('PackagesApp', () => { store, stubs: { ...stubChildren(PackagesApp), + PackageTitle: false, + TitleArea: false, GlButton: false, GlModal: false, GlTab: false, @@ -93,8 +99,14 @@ describe('PackagesApp', () => { const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata); const findInstallationCommands = () => wrapper.find(InstallationCommands); + beforeEach(() => { + delete window.location; + window.location = { replace: jest.fn() }; + }); + afterEach(() => { wrapper.destroy(); + window.location = location; }); it('renders the app and displays the package title', () => { @@ -238,44 +250,94 @@ describe('PackagesApp', () => { }); }); - describe('tracking', () => { - let eventSpy; - let utilSpy; - const category = 'foo'; + describe('tracking and delete', () => { + const doDelete = async () => { + deleteButton().trigger('click'); + await wrapper.vm.$nextTick(); + modalDeleteButton().trigger('click'); + }; + + describe('delete', () => { + const originalReferrer = document.referrer; + const setReferrer = (value = defaultProjectName) => { + Object.defineProperty(document, 'referrer', { + value, + configurable: true, + }); + }; + + afterEach(() => { + Object.defineProperty(document, 'referrer', { + value: originalReferrer, + configurable: true, + }); + }); - beforeEach(() => { - eventSpy = jest.spyOn(Tracking, 'event'); - utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); - }); + it('calls the proper vuex action', async () => { + createComponent({ packageEntity: npmPackage }); + await doDelete(); + expect(deletePackage).toHaveBeenCalled(); + }); - it('tracking category calls packageTypeToTrackCategory', () => { - createComponent({ packageEntity: conanPackage }); - expect(wrapper.vm.tracking.category).toBe(category); - expect(utilSpy).toHaveBeenCalledWith('conan'); + it('when referrer contains project name calls window.replace with project url', async () => { + setReferrer(); + deletePackage.mockResolvedValue(); + createComponent({ packageEntity: npmPackage }); + await doDelete(); + await deletePackage(); + expect(window.location.replace).toHaveBeenCalledWith( + 'project_url?showSuccessDeleteAlert=true', + ); + }); + + it('when referrer does not contain project name calls window.replace with group url', async () => { + setReferrer('baz'); + deletePackage.mockResolvedValue(); + createComponent({ packageEntity: npmPackage }); + await doDelete(); + await deletePackage(); + expect(window.location.replace).toHaveBeenCalledWith( + 'group_url?showSuccessDeleteAlert=true', + ); + }); }); - it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, () => { - createComponent({ packageEntity: conanPackage }); - deleteButton().trigger('click'); - return wrapper.vm.$nextTick().then(() => { - modalDeleteButton().trigger('click'); + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + createComponent({ packageEntity: conanPackage }); + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it(`delete button on delete modal call event with ${TrackingActions.DELETE_PACKAGE}`, async () => { + createComponent({ packageEntity: npmPackage }); + await doDelete(); expect(eventSpy).toHaveBeenCalledWith( category, TrackingActions.DELETE_PACKAGE, expect.any(Object), ); }); - }); - it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { - createComponent({ packageEntity: conanPackage }); + it(`file download link call event with ${TrackingActions.PULL_PACKAGE}`, () => { + createComponent({ packageEntity: conanPackage }); - firstFileDownloadLink().vm.$emit('click'); - expect(eventSpy).toHaveBeenCalledWith( - category, - TrackingActions.PULL_PACKAGE, - expect.any(Object), - ); + firstFileDownloadLink().vm.$emit('click'); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.PULL_PACKAGE, + expect.any(Object), + ); + }); }); }); }); diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js index 7679d721391..c13981fbb87 100644 --- a/spec/frontend/packages/details/components/composer_installation_spec.js +++ b/spec/frontend/packages/details/components/composer_installation_spec.js @@ -4,7 +4,7 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; import { registryUrl as composerHelpPath } from 'jest/packages/details/mock_data'; import { composerPackage as packageEntity } from 'jest/packages/mock_data'; import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; -import CodeInstructions from '~/packages/details/components/code_instruction.vue'; + import { TrackingActions } from '~/packages/details/constants'; const localVue = createLocalVue(); @@ -27,9 +27,8 @@ describe('ComposerInstallation', () => { }, }); - const findCodeInstructions = () => wrapper.findAll(CodeInstructions); - const findRegistryIncludeTitle = () => wrapper.find('[data-testid="registry-include-title"]'); - const findPackageIncludeTitle = () => wrapper.find('[data-testid="package-include-title"]'); + const findRegistryInclude = () => wrapper.find('[data-testid="registry-include"]'); + const findPackageInclude = () => wrapper.find('[data-testid="package-include"]'); const findHelpText = () => wrapper.find('[data-testid="help-text"]'); const findHelpLink = () => wrapper.find(GlLink); @@ -53,7 +52,7 @@ describe('ComposerInstallation', () => { describe('registry include command', () => { it('uses code_instructions', () => { - const registryIncludeCommand = findCodeInstructions().at(0); + const registryIncludeCommand = findRegistryInclude(); expect(registryIncludeCommand.exists()).toBe(true); expect(registryIncludeCommand.props()).toMatchObject({ instruction: composerRegistryIncludeStr, @@ -63,13 +62,13 @@ describe('ComposerInstallation', () => { }); it('has the correct title', () => { - expect(findRegistryIncludeTitle().text()).toBe('composer.json registry include'); + expect(findRegistryInclude().props('label')).toBe('composer.json registry include'); }); }); describe('package include command', () => { it('uses code_instructions', () => { - const registryIncludeCommand = findCodeInstructions().at(1); + const registryIncludeCommand = findPackageInclude(); expect(registryIncludeCommand.exists()).toBe(true); expect(registryIncludeCommand.props()).toMatchObject({ instruction: composerPackageIncludeStr, @@ -79,7 +78,7 @@ describe('ComposerInstallation', () => { }); it('has the correct title', () => { - expect(findPackageIncludeTitle().text()).toBe('composer.json require package include'); + expect(findPackageInclude().props('label')).toBe('composer.json require package include'); }); it('has the correct help text', () => { diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js index 5b31e38dad5..c79d1bb50dd 100644 --- a/spec/frontend/packages/details/components/conan_installation_spec.js +++ b/spec/frontend/packages/details/components/conan_installation_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import ConanInstallation from '~/packages/details/components/conan_installation.vue'; -import CodeInstructions from '~/packages/details/components/code_instruction.vue'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; import { conanPackage as packageEntity } from '../../mock_data'; import { registryUrl as conanPath } from '../mock_data'; diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js index 5d0007294b6..f301a03a7f3 100644 --- a/spec/frontend/packages/details/components/maven_installation_spec.js +++ b/spec/frontend/packages/details/components/maven_installation_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { registryUrl as mavenPath } from 'jest/packages/details/mock_data'; import { mavenPackage as packageEntity } from 'jest/packages/mock_data'; import MavenInstallation from '~/packages/details/components/maven_installation.vue'; -import CodeInstructions from '~/packages/details/components/code_instruction.vue'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions } from '~/packages/details/constants'; const localVue = createLocalVue(); diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js index f47bac57a66..4223a05453c 100644 --- a/spec/frontend/packages/details/components/npm_installation_spec.js +++ b/spec/frontend/packages/details/components/npm_installation_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { npmPackage as packageEntity } from 'jest/packages/mock_data'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import NpmInstallation from '~/packages/details/components/npm_installation.vue'; -import CodeInstructions from '~/packages/details/components/code_instruction.vue'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions } from '~/packages/details/constants'; import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters'; @@ -78,7 +78,7 @@ describe('NpmInstallation', () => { .at(2) .props(), ).toMatchObject({ - instruction: 'echo @Test:registry=undefined >> .npmrc', + instruction: 'echo @Test:registry=undefined/ >> .npmrc', multiline: false, trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND, }); @@ -90,7 +90,7 @@ describe('NpmInstallation', () => { .at(3) .props(), ).toMatchObject({ - instruction: 'echo \\"@Test:registry\\" \\"undefined\\" >> .yarnrc', + instruction: 'echo \\"@Test:registry\\" \\"undefined/\\" >> .yarnrc', multiline: false, trackingAction: TrackingActions.COPY_YARN_SETUP_COMMAND, }); diff --git a/spec/frontend/packages/details/components/nuget_installation_spec.js b/spec/frontend/packages/details/components/nuget_installation_spec.js index a23bf9a18a1..b381d131e94 100644 --- a/spec/frontend/packages/details/components/nuget_installation_spec.js +++ b/spec/frontend/packages/details/components/nuget_installation_spec.js @@ -3,7 +3,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import { nugetPackage as packageEntity } from 'jest/packages/mock_data'; import { registryUrl as nugetPath } from 'jest/packages/details/mock_data'; import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; -import CodeInstructions from '~/packages/details/components/code_instruction.vue'; +import CodeInstructions from '~/vue_shared/components/registry/code_instruction.vue'; import { TrackingActions } from '~/packages/details/constants'; const localVue = createLocalVue(); diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages/details/components/package_history_spec.js index e293e119585..f745a457b0a 100644 --- a/spec/frontend/packages/details/components/package_history_spec.js +++ b/spec/frontend/packages/details/components/package_history_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink, GlSprintf } from '@gitlab/ui'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import HistoryItem from '~/vue_shared/components/registry/history_item.vue'; import component from '~/packages/details/components/package_history.vue'; import { mavenPackage, mockPipelineInfo } from '../../mock_data'; @@ -16,7 +17,10 @@ describe('Package History', () => { wrapper = shallowMount(component, { propsData: { ...defaultProps, ...props }, stubs: { - HistoryElement: '<div data-testid="history-element"><slot></slot></div>', + HistoryItem: { + props: HistoryItem.props, + template: '<div data-testid="history-element"><slot></slot></div>', + }, GlSprintf, }, }); diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js index a30dc4b8aba..d0ed78418af 100644 --- a/spec/frontend/packages/details/components/package_title_spec.js +++ b/spec/frontend/packages/details/components/package_title_spec.js @@ -2,6 +2,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import PackageTitle from '~/packages/details/components/package_title.vue'; import PackageTags from '~/packages/shared/components/package_tags.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { conanPackage, mavenFiles, @@ -39,10 +40,14 @@ describe('PackageTitle', () => { wrapper = shallowMount(PackageTitle, { localVue, store, + stubs: { + TitleArea, + }, }); + return wrapper.vm.$nextTick(); } - const packageIcon = () => wrapper.find('[data-testid="package-icon"]'); + const findTitleArea = () => wrapper.find(TitleArea); const packageType = () => wrapper.find('[data-testid="package-type"]'); const packageSize = () => wrapper.find('[data-testid="package-size"]'); const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); @@ -54,72 +59,74 @@ describe('PackageTitle', () => { }); describe('renders', () => { - it('without tags', () => { - createComponent(); + it('without tags', async () => { + await createComponent(); expect(wrapper.element).toMatchSnapshot(); }); - it('with tags', () => { - createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); + it('with tags', async () => { + await createComponent({ packageEntity: { ...mavenPackage, tags: mockTags } }); expect(wrapper.element).toMatchSnapshot(); }); }); - describe('package icon', () => { - const fakeSrc = 'a-fake-src'; - - it('shows an icon when provided one from vuex', () => { - createComponent({ icon: fakeSrc }); + describe('package title', () => { + it('is correctly bound', async () => { + await createComponent(); - expect(packageIcon().exists()).toBe(true); + expect(findTitleArea().props('title')).toBe('Test package'); }); + }); - it('has the correct src attribute', () => { - createComponent({ icon: fakeSrc }); + describe('package icon', () => { + const fakeSrc = 'a-fake-src'; + + it('binds an icon when provided one from vuex', async () => { + await createComponent({ icon: fakeSrc }); - expect(packageIcon().props('src')).toBe(fakeSrc); + expect(findTitleArea().props('avatar')).toBe(fakeSrc); }); - it('does not show an icon when not provided one', () => { - createComponent(); + it('do not binds an icon when not provided one', async () => { + await createComponent(); - expect(packageIcon().exists()).toBe(false); + expect(findTitleArea().props('avatar')).toBe(null); }); }); describe.each` - packageEntity | expectedResult + packageEntity | text ${conanPackage} | ${'conan'} ${mavenPackage} | ${'maven'} ${npmPackage} | ${'npm'} ${nugetPackage} | ${'nuget'} - `(`package type`, ({ packageEntity, expectedResult }) => { + `(`package type`, ({ packageEntity, text }) => { beforeEach(() => createComponent({ packageEntity })); - it(`${packageEntity.package_type} should render from Vuex getters ${expectedResult}`, () => { - expect(packageType().text()).toBe(expectedResult); + it(`${packageEntity.package_type} should render from Vuex getters ${text}`, () => { + expect(packageType().props()).toEqual(expect.objectContaining({ text, icon: 'package' })); }); }); describe('calculates the package size', () => { - it('correctly calulates when there is only 1 file', () => { - createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); + it('correctly calculates when there is only 1 file', async () => { + await createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); - expect(packageSize().text()).toBe('200 bytes'); + expect(packageSize().props()).toMatchObject({ text: '200 bytes', icon: 'disk' }); }); - it('correctly calulates when there are multiple files', () => { - createComponent(); + it('correctly calulates when there are multiple files', async () => { + await createComponent(); - expect(packageSize().text()).toBe('300 bytes'); + expect(packageSize().props('text')).toBe('300 bytes'); }); }); describe('package tags', () => { - it('displays the package-tags component when the package has tags', () => { - createComponent({ + it('displays the package-tags component when the package has tags', async () => { + await createComponent({ packageEntity: { ...npmPackage, tags: mockTags, @@ -129,40 +136,44 @@ describe('PackageTitle', () => { expect(packageTags().exists()).toBe(true); }); - it('does not display the package-tags component when there are no tags', () => { - createComponent(); + it('does not display the package-tags component when there are no tags', async () => { + await createComponent(); expect(packageTags().exists()).toBe(false); }); }); describe('package ref', () => { - it('does not display the ref if missing', () => { - createComponent(); + it('does not display the ref if missing', async () => { + await createComponent(); expect(packageRef().exists()).toBe(false); }); - it('correctly shows the package ref if there is one', () => { - createComponent({ packageEntity: npmPackage }); - - expect(packageRef().contains('gl-icon-stub')).toBe(true); - expect(packageRef().text()).toBe(npmPackage.pipeline.ref); + it('correctly shows the package ref if there is one', async () => { + await createComponent({ packageEntity: npmPackage }); + expect(packageRef().props()).toMatchObject({ + text: npmPackage.pipeline.ref, + icon: 'branch', + }); }); }); describe('pipeline project', () => { - it('does not display the project if missing', () => { - createComponent(); + it('does not display the project if missing', async () => { + await createComponent(); expect(pipelineProject().exists()).toBe(false); }); - it('correctly shows the pipeline project if there is one', () => { - createComponent({ packageEntity: npmPackage }); + it('correctly shows the pipeline project if there is one', async () => { + await createComponent({ packageEntity: npmPackage }); - expect(pipelineProject().text()).toBe(npmPackage.pipeline.project.name); - expect(pipelineProject().attributes('href')).toBe(npmPackage.pipeline.project.web_url); + expect(pipelineProject().props()).toMatchObject({ + text: npmPackage.pipeline.project.name, + icon: 'review-list', + link: npmPackage.pipeline.project.web_url, + }); }); }); }); diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js index 6dfb2b63f85..70f87d18bcb 100644 --- a/spec/frontend/packages/details/store/actions_spec.js +++ b/spec/frontend/packages/details/store/actions_spec.js @@ -1,9 +1,10 @@ import testAction from 'helpers/vuex_action_helper'; import Api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; -import fetchPackageVersions from '~/packages/details/store/actions'; +import { fetchPackageVersions, deletePackage } from '~/packages/details/store/actions'; import * as types from '~/packages/details/store/mutation_types'; import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; +import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; import { npmPackage as packageEntity } from '../../mock_data'; jest.mock('~/flash.js'); @@ -73,4 +74,25 @@ describe('Actions Package details store', () => { ); }); }); + + describe('deletePackage', () => { + it('should call Api.deleteProjectPackage', done => { + Api.deleteProjectPackage = jest.fn().mockResolvedValue(); + testAction(deletePackage, undefined, { packageEntity }, [], [], () => { + expect(Api.deleteProjectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + done(); + }); + }); + it('should create flash on API error', done => { + Api.deleteProjectPackage = jest.fn().mockRejectedValue(); + + testAction(deletePackage, undefined, { packageEntity }, [], [], () => { + expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + done(); + }); + }); + }); }); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js index 307976d4124..0e95ee4cfd3 100644 --- a/spec/frontend/packages/details/store/getters_spec.js +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -62,9 +62,9 @@ describe('Getters PackageDetails Store', () => { const mavenSetupXmlBlock = generateMavenSetupXml(); const npmInstallStr = `npm i ${npmPackage.name}`; - const npmSetupStr = `echo @Test:registry=${registryUrl} >> .npmrc`; + const npmSetupStr = `echo @Test:registry=${registryUrl}/ >> .npmrc`; const yarnInstallStr = `yarn add ${npmPackage.name}`; - const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}\\" >> .yarnrc`; + const yarnSetupStr = `echo \\"@Test:registry\\" \\"${registryUrl}/\\" >> .yarnrc`; const nugetInstallationCommandStr = `nuget install ${nugetPackage.name} -Source "GitLab"`; const nugetSetupCommandStr = `nuget source Add -Name "GitLab" -Source "${registryUrl}" -UserName <your_username> -Password <your_token>`; diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap index 2b7a4c83bed..6ff9376565a 100644 --- a/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -444,7 +444,7 @@ exports[`packages_list_app renders 1`] = ` </template> <template> <div - class="d-flex align-self-center ml-md-auto py-1 py-md-0" + class="gl-display-flex gl-align-self-center gl-py-2 gl-flex-grow-1 gl-justify-content-end" > <package-filter-stub class="mr-1" diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js index 31bab3886c1..19ff4290f50 100644 --- a/spec/frontend/packages/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -1,7 +1,14 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import * as commonUtils from '~/lib/utils/common_utils'; +import createFlash from '~/flash'; import PackageListApp from '~/packages/list/components/packages_list_app.vue'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages/shared/constants'; +import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages/list/constants'; + +jest.mock('~/lib/utils/common_utils'); +jest.mock('~/flash'); const localVue = createLocalVue(); localVue.use(Vuex); @@ -145,4 +152,46 @@ describe('packages_list_app', () => { ); }); }); + + describe('delete alert handling', () => { + const { location } = window.location; + const search = `?${SHOW_DELETE_SUCCESS_ALERT}=true`; + + beforeEach(() => { + createStore('foo'); + jest.spyOn(commonUtils, 'historyReplaceState').mockImplementation(() => {}); + delete window.location; + window.location = { + href: `foo_bar_baz${search}`, + search, + }; + }); + + afterEach(() => { + window.location = location; + }); + + it(`creates a flash if the query string contains ${SHOW_DELETE_SUCCESS_ALERT}`, () => { + mountComponent(); + + expect(createFlash).toHaveBeenCalledWith({ + message: DELETE_PACKAGE_SUCCESS_MESSAGE, + type: 'notice', + }); + }); + + it('calls historyReplaceState with a clean url', () => { + mountComponent(); + + expect(commonUtils.historyReplaceState).toHaveBeenCalledWith('foo_bar_baz'); + }); + + it(`does nothing if the query string does not contain ${SHOW_DELETE_SUCCESS_ALERT}`, () => { + window.location.search = ''; + mountComponent(); + + expect(createFlash).not.toHaveBeenCalled(); + expect(commonUtils.historyReplaceState).not.toHaveBeenCalled(); + }); + }); }); diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages/list/components/packages_list_spec.js index a90d5056212..f981cc2851a 100644 --- a/spec/frontend/packages/list/components/packages_list_spec.js +++ b/spec/frontend/packages/list/components/packages_list_spec.js @@ -18,13 +18,12 @@ describe('packages_list', () => { let wrapper; let store; - const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' }; const EmptySlotStub = { name: 'empty-slot-stub', template: '<div>bar</div>' }; const findPackagesListLoader = () => wrapper.find(PackagesListLoader); const findPackageListPagination = () => wrapper.find(GlPagination); const findPackageListDeleteModal = () => wrapper.find(GlModal); - const findEmptySlot = () => wrapper.find({ name: 'empty-slot-stub' }); + const findEmptySlot = () => wrapper.find(EmptySlotStub); const findPackagesListRow = () => wrapper.find(PackagesListRow); const createStore = (isGroupPage, packages, isLoading) => { @@ -67,7 +66,6 @@ describe('packages_list', () => { stubs: { ...stubChildren(PackagesList), GlTable, - GlSortingItem, GlModal, }, ...options, diff --git a/spec/frontend/packages/list/components/packages_sort_spec.js b/spec/frontend/packages/list/components/packages_sort_spec.js index ff3e8e19413..5c4794d8f63 100644 --- a/spec/frontend/packages/list/components/packages_sort_spec.js +++ b/spec/frontend/packages/list/components/packages_sort_spec.js @@ -1,5 +1,5 @@ import Vuex from 'vuex'; -import { GlSorting } from '@gitlab/ui'; +import { GlSorting, GlSortingItem } from '@gitlab/ui'; import { mount, createLocalVue } from '@vue/test-utils'; import stubChildren from 'helpers/stub_children'; import PackagesSort from '~/packages/list/components/packages_sort.vue'; @@ -13,8 +13,6 @@ describe('packages_sort', () => { let sorting; let sortingItems; - const GlSortingItem = { name: 'sorting-item-stub', template: '<div><slot></slot></div>' }; - const findPackageListSorting = () => wrapper.find(GlSorting); const findSortingItems = () => wrapper.findAll(GlSortingItem); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js index faa629cc01f..cf205ecbac4 100644 --- a/spec/frontend/packages/list/stores/actions_spec.js +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -5,7 +5,8 @@ import Api from '~/api'; import { deprecatedCreateFlash as createFlash } from '~/flash'; import * as actions from '~/packages/list/stores/actions'; import * as types from '~/packages/list/stores/mutation_types'; -import { MISSING_DELETE_PATH_ERROR, DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/list/constants'; +import { MISSING_DELETE_PATH_ERROR } from '~/packages/list/constants'; +import { DELETE_PACKAGE_ERROR_MESSAGE } from '~/packages/shared/constants'; jest.mock('~/flash.js'); jest.mock('~/api.js'); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js index 86205b0744c..b95d06428ff 100644 --- a/spec/frontend/packages/mock_data.js +++ b/spec/frontend/packages/mock_data.js @@ -30,6 +30,7 @@ export const mavenPackage = { name: 'Test package', package_type: 'maven', project_path: 'foo/bar/baz', + projectPathName: 'foo/bar/baz', project_id: 1, updated_at: '2015-12-10', version: '1.0.0', @@ -59,6 +60,7 @@ export const npmPackage = { name: '@Test/package', package_type: 'npm', project_path: 'foo/bar/baz', + projectPathName: 'foo/bar/baz', project_id: 1, updated_at: '2015-12-10', version: '', @@ -86,6 +88,7 @@ export const conanPackage = { id: 3, name: 'conan-package', project_path: 'foo/bar/baz', + projectPathName: 'foo/bar/baz', package_files: [], package_type: 'conan', project_id: 1, diff --git a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap index eab8d7b67cc..6aaefed92d0 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -2,99 +2,143 @@ exports[`packages_list_row renders 1`] = ` <div - class="gl-responsive-table-row" - data-qa-selector="packages-row" + class="gl-display-flex gl-flex-direction-column gl-border-b-solid gl-border-t-solid gl-border-t-1 gl-border-b-1 gl-border-t-transparent gl-border-b-gray-100" + data-qa-selector="package_row" > <div - class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap" + class="gl-display-flex gl-align-items-center gl-py-5" > - <div - class="d-flex align-items-center mr-2" - > - <gl-link-stub - class="text-dark font-weight-bold mb-md-1" - data-qa-selector="package_link" - href="foo" - > - - Test package - - </gl-link-stub> - - <!----> - </div> + <!----> <div - class="d-flex text-secondary text-truncate mt-md-2" + class="gl-display-flex gl-xs-flex-direction-column gl-justify-content-space-between gl-align-items-stretch gl-flex-fill-1" > - <span> - 1.0.0 - </span> - - <!----> - <div - class="d-flex align-items-center" + class="gl-display-flex gl-flex-direction-column gl-justify-content-space-between gl-xs-mb-3 gl-min-w-0 gl-flex-grow-1" > - <gl-icon-stub - class="text-secondary ml-2 mr-1" - name="review-list" - size="16" - /> + <div + class="gl-display-flex gl-align-items-center gl-text-body gl-font-weight-bold gl-min-h-6 gl-min-w-0" + > + <div + class="gl-display-flex gl-align-items-center gl-mr-3 gl-min-w-0" + > + <gl-link-stub + class="gl-text-body gl-min-w-0" + data-qa-selector="package_link" + href="foo" + > + <gl-truncate-stub + position="end" + text="Test package" + /> + </gl-link-stub> + + <!----> + </div> + + <!----> + </div> - <gl-link-stub - class="text-secondary" - data-testid="packages-row-project" - href="/foo/bar/baz" + <div + class="gl-display-flex gl-align-items-center gl-text-gray-500 gl-mt-1 gl-min-h-6 gl-min-w-0 gl-flex-fill-1" > - - </gl-link-stub> + <div + class="gl-display-flex" + > + <span> + 1.0.0 + </span> + + <!----> + + <div + class="gl-display-flex gl-align-items-center" + > + <gl-icon-stub + class="gl-ml-3 gl-mr-2 gl-min-w-0" + name="review-list" + size="16" + /> + + <gl-link-stub + class="gl-text-body gl-min-w-0" + data-testid="packages-row-project" + href="/foo/bar/baz" + > + <gl-truncate-stub + position="end" + text="foo/bar/baz" + /> + </gl-link-stub> + </div> + + <div + class="d-flex align-items-center" + data-testid="package-type" + > + <gl-icon-stub + class="gl-ml-3 gl-mr-2" + name="package" + size="16" + /> + + <span> + Maven + </span> + </div> + </div> + </div> </div> <div - class="d-flex align-items-center" - data-testid="package-type" + class="gl-display-flex gl-flex-direction-column gl-sm-align-items-flex-end gl-justify-content-space-between gl-text-gray-500 gl-flex-shrink-0" > - <gl-icon-stub - class="text-secondary ml-2 mr-1" - name="package" - size="16" - /> + <div + class="gl-display-flex gl-align-items-center gl-sm-text-body gl-sm-font-weight-bold gl-min-h-6" + > + <publish-method-stub + packageentity="[object Object]" + /> + </div> - <span> - Maven - </span> + <div + class="gl-display-flex gl-align-items-center gl-mt-1 gl-min-h-6" + > + <span> + <gl-sprintf-stub + message="Created %{timestamp}" + /> + </span> + </div> </div> </div> - </div> - - <div - class="table-section d-flex flex-md-column justify-content-between align-items-md-end section-40" - > - <publish-method-stub - packageentity="[object Object]" - /> <div - class="text-secondary order-0 order-md-1 mt-md-2" + class="gl-w-9 gl-display-none gl-display-sm-flex gl-justify-content-end gl-pr-1" > - <gl-sprintf-stub - message="Created %{timestamp}" + <gl-button-stub + aria-label="Remove package" + category="primary" + data-testid="action-delete" + icon="remove" + size="medium" + title="Remove package" + variant="danger" /> </div> </div> <div - class="table-section section-10 d-flex justify-content-end" + class="gl-display-flex" > - <gl-button-stub - aria-label="Remove package" - category="primary" - data-testid="action-delete" - icon="remove" - size="medium" - title="Remove package" - variant="danger" + <div + class="gl-w-7" + /> + + <!----> + + <div + class="gl-w-9" /> </div> </div> diff --git a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap index 5ecca63d41d..9a0c52cee47 100644 --- a/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap +++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap @@ -2,35 +2,37 @@ exports[`publish_method renders 1`] = ` <div - class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1" + class="gl-display-flex gl-align-items-center" > <gl-icon-stub - class="mr-1" + class="gl-mr-2" name="git-merge" size="16" /> - <strong - class="mr-1 text-dark" + <span + class="gl-mr-2" + data-testid="pipeline-ref" > branch-name - </strong> + </span> <gl-icon-stub - class="mr-1" + class="gl-mr-2" name="commit" size="16" /> <gl-link-stub - class="mr-1" + class="gl-mr-2" + data-testid="pipeline-sha" href="../commit/sha-baz" > sha-baz </gl-link-stub> <clipboard-button-stub - cssclass="border-0 text-secondary py-0 px-1" + cssclass="gl-border-0 gl-py-0 gl-px-2" text="sha-baz" title="Copy commit SHA" tooltipplacement="top" diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js index c0ae972d519..f4eabf7bb67 100644 --- a/spec/frontend/packages/shared/components/package_list_row_spec.js +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -1,6 +1,7 @@ -import { mount, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; import PackageTags from '~/packages/shared/components/package_tags.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; import { packageList } from '../../mock_data'; describe('packages_list_row', () => { @@ -17,14 +18,12 @@ describe('packages_list_row', () => { const mountComponent = ({ isGroup = false, packageEntity = packageWithoutTags, - shallow = true, showPackageType = true, disableDelete = false, } = {}) => { - const mountFunc = shallow ? shallowMount : mount; - - wrapper = mountFunc(PackagesListRow, { + wrapper = shallowMount(PackagesListRow, { store, + stubs: { ListItem }, propsData: { packageLink: 'foo', packageEntity, @@ -92,15 +91,14 @@ describe('packages_list_row', () => { }); describe('delete event', () => { - beforeEach(() => mountComponent({ packageEntity: packageWithoutTags, shallow: false })); + beforeEach(() => mountComponent({ packageEntity: packageWithoutTags })); - it('emits the packageToDelete event when the delete button is clicked', () => { - findDeleteButton().trigger('click'); + it('emits the packageToDelete event when the delete button is clicked', async () => { + findDeleteButton().vm.$emit('click'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('packageToDelete')).toBeTruthy(); - expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); - }); + await wrapper.vm.$nextTick(); + expect(wrapper.emitted('packageToDelete')).toBeTruthy(); + expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); }); }); }); diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages/shared/components/packages_list_loader_spec.js index c8c2e2a4ba4..115a3a7095d 100644 --- a/spec/frontend/packages/shared/components/packages_list_loader_spec.js +++ b/spec/frontend/packages/shared/components/packages_list_loader_spec.js @@ -12,8 +12,8 @@ describe('PackagesListLoader', () => { }); }; - const getShapes = () => wrapper.vm.desktopShapes; - const findSquareButton = () => wrapper.find({ ref: 'button-loader' }); + const findDesktopShapes = () => wrapper.find('[data-testid="desktop-loader"]'); + const findMobileShapes = () => wrapper.find('[data-testid="mobile-loader"]'); beforeEach(createComponent); @@ -22,21 +22,30 @@ describe('PackagesListLoader', () => { wrapper = null; }); - describe('when used for projects', () => { - it('should return 5 rects with last one being a square', () => { - expect(getShapes()).toHaveLength(5); - expect(findSquareButton().exists()).toBe(true); + describe('desktop loader', () => { + it('produces the right loader', () => { + expect(findDesktopShapes().findAll('rect[width="1000"]')).toHaveLength(20); + }); + + it('has the correct classes', () => { + expect(findDesktopShapes().classes()).toEqual([ + 'gl-display-none', + 'gl-display-sm-flex', + 'gl-flex-direction-column', + ]); }); }); - describe('when used for groups', () => { - beforeEach(() => { - createComponent({ isGroup: true }); + describe('mobile loader', () => { + it('produces the right loader', () => { + expect(findMobileShapes().findAll('rect[height="170"]')).toHaveLength(5); }); - it('should return 5 rects with no square', () => { - expect(getShapes()).toHaveLength(5); - expect(findSquareButton().exists()).toBe(false); + it('has the correct classes', () => { + expect(findMobileShapes().classes()).toEqual([ + 'gl-flex-direction-column', + 'gl-display-sm-none', + ]); }); }); }); diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages/shared/components/publish_method_spec.js index bb9287c1204..6014774990c 100644 --- a/spec/frontend/packages/shared/components/publish_method_spec.js +++ b/spec/frontend/packages/shared/components/publish_method_spec.js @@ -7,9 +7,9 @@ describe('publish_method', () => { const [packageWithoutPipeline, packageWithPipeline] = packageList; - const findPipelineRef = () => wrapper.find({ ref: 'pipeline-ref' }); - const findPipelineSha = () => wrapper.find({ ref: 'pipeline-sha' }); - const findManualPublish = () => wrapper.find({ ref: 'manual-ref' }); + const findPipelineRef = () => wrapper.find('[data-testid="pipeline-ref"]'); + const findPipelineSha = () => wrapper.find('[data-testid="pipeline-sha"]'); + const findManualPublish = () => wrapper.find('[data-testid="manually-published"]'); const mountComponent = (packageEntity = {}, isGroup = false) => { wrapper = shallowMount(PublishMethod, { diff --git a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap index fc37a545511..2fbc700d4f5 100644 --- a/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap +++ b/spec/frontend/pages/admin/users/components/__snapshots__/delete_user_modal_spec.js.snap @@ -3,14 +3,15 @@ exports[`User Operation confirmation modal renders modal with form included 1`] = ` <div> <p> - content + <gl-sprintf-stub + message="content" + /> </p> <p> - To confirm, type - <code> - username - </code> + <gl-sprintf-stub + message="To confirm, type %{username}" + /> </p> <form diff --git a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js index b3a297ac2c5..fbe2274c40d 100644 --- a/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js +++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlBanner } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; +import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; import CustomizeHomepageBanner from '~/pages/dashboard/projects/index/components/customize_homepage_banner.vue'; import axios from '~/lib/utils/axios_utils'; @@ -10,18 +11,22 @@ const provide = { preferencesBehaviorPath: 'some/behavior/path', calloutsPath: 'call/out/path', calloutsFeatureId: 'some-feature-id', + trackLabel: 'home_page', }; const createComponent = () => { - return shallowMount(CustomizeHomepageBanner, { provide }); + return shallowMount(CustomizeHomepageBanner, { provide, stubs: { GlBanner } }); }; describe('CustomizeHomepageBanner', () => { + let trackingSpy; let mockAxios; let wrapper; beforeEach(() => { mockAxios = new MockAdapter(axios); + document.body.dataset.page = 'some:page'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); wrapper = createComponent(); }); @@ -29,22 +34,75 @@ describe('CustomizeHomepageBanner', () => { wrapper.destroy(); wrapper = null; mockAxios.restore(); + unmockTracking(); }); it('should render the banner when not dismissed', () => { - expect(wrapper.contains(GlBanner)).toBe(true); + expect(wrapper.find(GlBanner).exists()).toBe(true); }); it('should close the banner when dismiss is clicked', async () => { mockAxios.onPost(provide.calloutsPath).replyOnce(200); - expect(wrapper.contains(GlBanner)).toBe(true); + expect(wrapper.find(GlBanner).exists()).toBe(true); wrapper.find(GlBanner).vm.$emit('close'); await wrapper.vm.$nextTick(); - expect(wrapper.contains(GlBanner)).toBe(false); + expect(wrapper.find(GlBanner).exists()).toBe(false); }); it('includes the body text from options', () => { expect(wrapper.html()).toContain(wrapper.vm.$options.i18n.body); }); + + describe('tracking', () => { + const preferencesTrackingEvent = 'click_go_to_preferences'; + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; + + it('sets the needed data attributes for tracking button', async () => { + await wrapper.vm.$nextTick(); + const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); + + expect(button.attributes('data-track-event')).toEqual(preferencesTrackingEvent); + expect(button.attributes('data-track-label')).toEqual(provide.trackLabel); + }); + + it('sends a tracking event when the banner is shown', () => { + const trackCategory = undefined; + const trackEvent = 'show_home_page_banner'; + + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, { + label: provide.trackLabel, + }); + }); + + it('sends a tracking event when the banner is dismissed', async () => { + mockTrackingOnWrapper(); + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + const trackCategory = undefined; + const trackEvent = 'click_dismiss'; + + wrapper.find(GlBanner).vm.$emit('close'); + + await wrapper.vm.$nextTick(); + expect(trackingSpy).toHaveBeenCalledWith(trackCategory, trackEvent, { + label: provide.trackLabel, + }); + }); + + it('sends a tracking event when the button is clicked', async () => { + mockTrackingOnWrapper(); + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + const button = wrapper.find(`[href='${wrapper.vm.preferencesBehaviorPath}']`); + + triggerEvent(button.element); + + await wrapper.vm.$nextTick(); + expect(trackingSpy).toHaveBeenCalledWith('_category_', preferencesTrackingEvent, { + label: provide.trackLabel, + }); + }); + }); }); diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index 204fe3d0a68..5ecb7860103 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -2,7 +2,6 @@ import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; import Todos from '~/pages/dashboard/todos/index/todos'; import '~/lib/utils/common_utils'; -import '~/gl_dropdown'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; import { visitUrl } from '~/lib/utils/url_utility'; diff --git a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js index 0bb96ee33d4..67ace608127 100644 --- a/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js +++ b/spec/frontend/pages/import/bitbucket_server/components/bitbucket_server_status_table_spec.js @@ -36,7 +36,7 @@ describe('BitbucketServerStatusTable', () => { it('renders bitbucket status table component', () => { createComponent(); - expect(wrapper.contains(BitbucketStatusTable)).toBe(true); + expect(wrapper.find(BitbucketStatusTable).exists()).toBe(true); }); it('renders Reconfigure button', async () => { diff --git a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js index 2ec608569e3..9993e4da980 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_groups_list_spec.js @@ -70,7 +70,7 @@ describe('Fork groups list component', () => { replyWith(() => new Promise(() => {})); createWrapper(); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); it('displays empty text if no groups are available', async () => { @@ -89,7 +89,7 @@ describe('Fork groups list component', () => { await waitForPromises(); - expect(wrapper.contains(GlSearchBoxByType)).toBe(true); + expect(wrapper.find(GlSearchBoxByType).exists()).toBe(true); }); it('renders list items for each available group', async () => { diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js index 54a080fb62b..8884f7815ab 100644 --- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js +++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js @@ -124,7 +124,7 @@ describe('Code Coverage', () => { }); it('renders the dropdown with all custom names as options', () => { - expect(wrapper.contains(GlDeprecatedDropdown)).toBeDefined(); + expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeDefined(); expect(findAllDropdownItems()).toHaveLength(codeCoverageMockData.length); expect(findFirstDropdownItem().text()).toBe(codeCoverageMockData[0].group_name); }); @@ -150,7 +150,11 @@ describe('Code Coverage', () => { .find(GlIcon) .exists(), ).toBe(false); - expect(findSecondDropdownItem().contains(GlIcon)).toBe(true); + expect( + findSecondDropdownItem() + .find(GlIcon) + .exists(), + ).toBe(true); }); it('updates the graph data when selecting a different option in dropdown', async () => { diff --git a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js index 4c73225b54c..5efcedf678b 100644 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ b/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import '~/gl_dropdown'; import TimezoneDropdown, { formatUtcOffset, formatTimezone, diff --git a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js index 8ab5426a005..1fd9d285610 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/project_feature_settings_spec.js @@ -27,14 +27,14 @@ describe('Project Feature Settings', () => { describe('Hidden name input', () => { it('should set the hidden name input if the name exists', () => { - expect(wrapper.find({ name: 'Test' }).props().value).toBe(1); + expect(wrapper.find(`input[name=${defaultProps.name}]`).attributes().value).toBe('1'); }); it('should not set the hidden name input if the name does not exist', () => { wrapper.setProps({ name: null }); return wrapper.vm.$nextTick(() => { - expect(wrapper.find({ name: 'Test' }).exists()).toBe(false); + expect(wrapper.find(`input[name=${defaultProps.name}]`).exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js index a50ceed5d09..e760cead760 100644 --- a/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js +++ b/spec/frontend/pages/projects/shared/permissions/components/settings_panel_spec.js @@ -40,7 +40,7 @@ const defaultProps = { pagesAvailable: true, pagesAccessControlEnabled: false, pagesAccessControlForced: false, - pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control-core', + pagesHelpPath: '/help/user/project/pages/introduction#gitlab-pages-access-control', packagesAvailable: false, packagesHelpPath: '/help/user/packages/index', }; diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js index b9dc4c9588c..ff51b1184cb 100644 --- a/spec/frontend/performance_bar/components/detailed_metric_spec.js +++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js @@ -30,7 +30,7 @@ describe('detailedMetric', () => { }); it('does not render the element', () => { - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); }); diff --git a/spec/frontend/performance_bar/components/request_warning_spec.js b/spec/frontend/performance_bar/components/request_warning_spec.js index 21f7bdf01f3..d558c7b018a 100644 --- a/spec/frontend/performance_bar/components/request_warning_spec.js +++ b/spec/frontend/performance_bar/components/request_warning_spec.js @@ -27,7 +27,7 @@ describe('request warning', () => { }); it('does nothing', () => { - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); }); }); diff --git a/spec/frontend/performance_bar/index_spec.js b/spec/frontend/performance_bar/index_spec.js index 621c7d87a7e..1517142c21e 100644 --- a/spec/frontend/performance_bar/index_spec.js +++ b/spec/frontend/performance_bar/index_spec.js @@ -44,7 +44,7 @@ describe('performance bar wrapper', () => { {}, ); - vm = performanceBar({ container: '#js-peek' }); + vm = performanceBar(peekWrapper); }); afterEach(() => { diff --git a/spec/frontend/performance_bar/services/performance_bar_service_spec.js b/spec/frontend/performance_bar/services/performance_bar_service_spec.js index cfec4b779e4..36bfd575c12 100644 --- a/spec/frontend/performance_bar/services/performance_bar_service_spec.js +++ b/spec/frontend/performance_bar/services/performance_bar_service_spec.js @@ -9,18 +9,12 @@ describe('PerformanceBarService', () => { it('returns false when the request URL is the peek URL', () => { expect( - fireCallback({ headers: { 'x-request-id': '123' }, url: '/peek' }, '/peek'), - ).toBeFalsy(); + fireCallback({ headers: { 'x-request-id': '123' }, config: { url: '/peek' } }, '/peek'), + ).toBe(false); }); it('returns false when there is no request ID', () => { - expect(fireCallback({ headers: {}, url: '/request' }, '/peek')).toBeFalsy(); - }); - - it('returns false when the request is an API request', () => { - expect( - fireCallback({ headers: { 'x-request-id': '123' }, url: '/api/' }, '/peek'), - ).toBeFalsy(); + expect(fireCallback({ headers: {}, config: { url: '/request' } }, '/peek')).toBe(false); }); it('returns false when the response is from the cache', () => { @@ -29,13 +23,22 @@ describe('PerformanceBarService', () => { { headers: { 'x-request-id': '123', 'x-gitlab-from-cache': 'true' }, url: '/request' }, '/peek', ), - ).toBeFalsy(); + ).toBe(false); + }); + + it('returns true when the request is an API request', () => { + expect( + fireCallback({ headers: { 'x-request-id': '123' }, config: { url: '/api/' } }, '/peek'), + ).toBe(true); }); - it('returns true when all conditions are met', () => { + it('returns true for all other requests', () => { expect( - fireCallback({ headers: { 'x-request-id': '123' }, url: '/request' }, '/peek'), - ).toBeTruthy(); + fireCallback( + { headers: { 'x-request-id': '123' }, config: { url: '/request' } }, + '/peek', + ), + ).toBe(true); }); }); @@ -45,7 +48,7 @@ describe('PerformanceBarService', () => { } it('gets the request ID from the headers', () => { - expect(requestId({ headers: { 'x-request-id': '123' } }, '/peek')).toEqual('123'); + expect(requestId({ headers: { 'x-request-id': '123' } }, '/peek')).toBe('123'); }); }); @@ -54,14 +57,10 @@ describe('PerformanceBarService', () => { return PerformanceBarService.callbackParams(response, peekUrl)[2]; } - it('gets the request URL from the response object', () => { - expect(requestUrl({ headers: {}, url: '/request' }, '/peek')).toEqual('/request'); - }); - - it('gets the request URL from response.config if present', () => { - expect( - requestUrl({ headers: {}, config: { url: '/config-url' }, url: '/request' }, '/peek'), - ).toEqual('/config-url'); + it('gets the request URL from response.config', () => { + expect(requestUrl({ headers: {}, config: { url: '/config-url' } }, '/peek')).toBe( + '/config-url', + ); }); }); }); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index d1e6b6b938a..97a92778f1a 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -1,31 +1,48 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GlNewDropdown, GlNewDropdownItem, GlForm } from '@gitlab/ui'; -import Api from '~/api'; +import { GlDropdown, GlDropdownItem, GlForm, GlSprintf } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; -import { mockRefs, mockParams, mockPostParams, mockProjectId } from '../mock_data'; +import { mockRefs, mockParams, mockPostParams, mockProjectId, mockError } from '../mock_data'; +import { redirectTo } from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), +})); + +const pipelinesPath = '/root/project/-/pipleines'; +const postResponse = { id: 1 }; describe('Pipeline New Form', () => { let wrapper; + let mock; const dummySubmitEvent = { preventDefault() {}, }; const findForm = () => wrapper.find(GlForm); - const findDropdown = () => wrapper.find(GlNewDropdown); - const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem); + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); + const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); + const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); + const findWarningAlertSummary = () => findWarningAlert().find(GlSprintf); + const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); + const getExpectedPostParams = () => JSON.parse(mock.history.post[0].data); const createComponent = (term = '', props = {}, method = shallowMount) => { wrapper = method(PipelineNewForm, { propsData: { projectId: mockProjectId, - pipelinesPath: '', + pipelinesPath, refs: mockRefs, defaultBranch: 'master', settingsLink: '', + maxWarnings: 25, ...props, }, data() { @@ -37,24 +54,30 @@ describe('Pipeline New Form', () => { }; beforeEach(() => { - jest.spyOn(Api, 'createPipeline').mockResolvedValue({ data: { web_url: '/' } }); + mock = new MockAdapter(axios); }); afterEach(() => { wrapper.destroy(); wrapper = null; + + mock.restore(); }); describe('Dropdown with branches and tags', () => { + beforeEach(() => { + mock.onPost(pipelinesPath).reply(200, postResponse); + }); + it('displays dropdown with all branches and tags', () => { createComponent(); - expect(findDropdownItems().length).toBe(mockRefs.length); + expect(findDropdownItems()).toHaveLength(mockRefs.length); }); it('when user enters search term the list is filtered', () => { createComponent('master'); - expect(findDropdownItems().length).toBe(1); + expect(findDropdownItems()).toHaveLength(1); expect( findDropdownItems() .at(0) @@ -66,43 +89,78 @@ describe('Pipeline New Form', () => { describe('Form', () => { beforeEach(() => { createComponent('', mockParams, mount); + + mock.onPost(pipelinesPath).reply(200, postResponse); }); - it('displays the correct values for the provided query params', () => { + it('displays the correct values for the provided query params', async () => { expect(findDropdown().props('text')).toBe('tag-1'); - return wrapper.vm.$nextTick().then(() => { - expect(findVariableRows().length).toBe(3); - }); + await wrapper.vm.$nextTick(); + + expect(findVariableRows()).toHaveLength(3); }); it('does not display remove icon for last row', () => { - expect(findRemoveIcons().length).toBe(2); + expect(findRemoveIcons()).toHaveLength(2); }); - it('removes ci variable row on remove icon button click', () => { + it('removes ci variable row on remove icon button click', async () => { findRemoveIcons() .at(1) .trigger('click'); - return wrapper.vm.$nextTick().then(() => { - expect(findVariableRows().length).toBe(2); - }); + await wrapper.vm.$nextTick(); + + expect(findVariableRows()).toHaveLength(2); }); - it('creates a pipeline on submit', () => { + it('creates a pipeline on submit', async () => { findForm().vm.$emit('submit', dummySubmitEvent); - expect(Api.createPipeline).toHaveBeenCalledWith(mockProjectId, mockPostParams); + await waitForPromises(); + + expect(getExpectedPostParams()).toEqual(mockPostParams); + expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${postResponse.id}`); }); - it('creates blank variable on input change event', () => { + it('creates blank variable on input change event', async () => { findKeyInputs() .at(2) .trigger('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findVariableRows().length).toBe(4); - }); + await wrapper.vm.$nextTick(); + + expect(findVariableRows()).toHaveLength(4); + }); + }); + + describe('Form errors and warnings', () => { + beforeEach(() => { + createComponent(); + + mock.onPost(pipelinesPath).reply(400, mockError); + + findForm().vm.$emit('submit', dummySubmitEvent); + + return waitForPromises(); + }); + + it('shows both error and warning', () => { + expect(findErrorAlert().exists()).toBe(true); + expect(findWarningAlert().exists()).toBe(true); + }); + + it('shows the correct error', () => { + expect(findErrorAlert().text()).toBe(mockError.errors[0]); + }); + + it('shows the correct warning title', () => { + const { length } = mockError.warnings; + expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`); + }); + + it('shows the correct amount of warnings', () => { + expect(findWarnings()).toHaveLength(mockError.warnings.length); }); }); }); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index 55ec1fb5afc..55286e0ec7e 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -19,3 +19,15 @@ export const mockPostParams = { { key: 'test_file', value: 'test_file_val', variable_type: 'file' }, ], }; + +export const mockError = { + errors: [ + 'test job: chosen stage does not exist; available stages are .pre, build, test, deploy, .post', + ], + warnings: [ + 'jobs:build1 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings', + 'jobs:build2 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings', + 'jobs:build3 may allow multiple pipelines to run for a single action due to `rules:when` clause with no `workflow:rules` - read more: https://docs.gitlab.com/ee/ci/troubleshooting.html#pipeline-warnings', + ], + total_warnings: 7, +}; diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index c5b7318d3af..8a6586a7d7d 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -47,9 +47,6 @@ describe('Pipelines filtered search', () => { }); it('displays UI elements', () => { - expect(wrapper.isVueInstance()).toBe(true); - expect(wrapper.isEmpty()).toBe(false); - expect(findFilteredSearch().exists()).toBe(true); }); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 1389649abea..d977db58a0e 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -16,6 +16,9 @@ describe('graph component', () => { let wrapper; + const findExpandPipelineBtn = () => wrapper.find('[data-testid="expandPipelineButton"]'); + const findAllExpandPipelineBtns = () => wrapper.findAll('[data-testid="expandPipelineButton"]'); + beforeEach(() => { setHTMLFixture('<div class="layout-page"></div>'); }); @@ -167,7 +170,7 @@ describe('graph component', () => { describe('triggered by', () => { describe('on click', () => { it('should emit `onClickTriggeredBy` when triggered by linked pipeline is clicked', () => { - const btnWrapper = wrapper.find('.linked-pipeline-content'); + const btnWrapper = findExpandPipelineBtn(); btnWrapper.trigger('click'); @@ -213,7 +216,7 @@ describe('graph component', () => { ), }); - const btnWrappers = wrapper.findAll('.linked-pipeline-content'); + const btnWrappers = findAllExpandPipelineBtns(); const downstreamBtnWrapper = btnWrappers.at(btnWrappers.length - 1); downstreamBtnWrapper.trigger('click'); diff --git a/spec/frontend/pipelines/graph/job_item_spec.js b/spec/frontend/pipelines/graph/job_item_spec.js index 2c5e7a1f6e9..e844cbc5bf8 100644 --- a/spec/frontend/pipelines/graph/job_item_spec.js +++ b/spec/frontend/pipelines/graph/job_item_spec.js @@ -6,6 +6,7 @@ describe('pipeline graph job item', () => { let wrapper; const findJobWithoutLink = () => wrapper.find('[data-testid="job-without-link"]'); + const findJobWithLink = () => wrapper.find('[data-testid="job-with-link"]'); const createWrapper = propsData => { wrapper = mount(JobItem, { @@ -13,6 +14,7 @@ describe('pipeline graph job item', () => { }); }; + const triggerActiveClass = 'gl-shadow-x0-y0-b3-s1-blue-500'; const delayedJobFixture = getJSONFixture('jobs/delayed.json'); const mockJob = { id: 4256, @@ -33,6 +35,18 @@ describe('pipeline graph job item', () => { }, }, }; + const mockJobWithoutDetails = { + id: 4257, + name: 'job_without_details', + status: { + icon: 'status_success', + text: 'passed', + label: 'passed', + group: 'success', + details_path: '/root/ci-mock/builds/4257', + has_details: false, + }, + }; afterEach(() => { wrapper.destroy(); @@ -47,7 +61,7 @@ describe('pipeline graph job item', () => { expect(link.attributes('href')).toBe(mockJob.status.details_path); - expect(link.attributes('title')).toEqual(`${mockJob.name} - ${mockJob.status.label}`); + expect(link.attributes('title')).toBe(`${mockJob.name} - ${mockJob.status.label}`); expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); @@ -61,18 +75,7 @@ describe('pipeline graph job item', () => { describe('name without link', () => { beforeEach(() => { createWrapper({ - job: { - id: 4257, - name: 'test', - status: { - icon: 'status_success', - text: 'passed', - label: 'passed', - group: 'success', - details_path: '/root/ci-mock/builds/4257', - has_details: false, - }, - }, + job: mockJobWithoutDetails, cssClassJobName: 'css-class-job-name', jobHovered: 'test', }); @@ -82,11 +85,10 @@ describe('pipeline graph job item', () => { expect(wrapper.find('.ci-status-icon-success').exists()).toBe(true); expect(wrapper.find('a').exists()).toBe(false); - expect(trimText(wrapper.find('.ci-status-text').text())).toEqual(mockJob.name); + expect(trimText(wrapper.find('.ci-status-text').text())).toBe(mockJobWithoutDetails.name); }); it('should apply hover class and provided class name', () => { - expect(findJobWithoutLink().classes()).toContain('gl-inset-border-1-blue-500'); expect(findJobWithoutLink().classes()).toContain('css-class-job-name'); }); }); @@ -137,9 +139,7 @@ describe('pipeline graph job item', () => { }, }); - expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toEqual( - 'test - success', - ); + expect(wrapper.find('.js-job-component-tooltip').attributes('title')).toBe('test - success'); }); }); @@ -149,9 +149,39 @@ describe('pipeline graph job item', () => { job: delayedJobFixture, }); - expect(wrapper.find('.js-pipeline-graph-job-link').attributes('title')).toEqual( + expect(findJobWithLink().attributes('title')).toBe( `delayed job - delayed manual action (${wrapper.vm.remainingTime})`, ); }); }); + + describe('trigger job highlighting', () => { + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${true} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${true} | ${false} + `( + `trigger job should stay highlighted when downstream is expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ job, pipelineExpanded: { jobName, expanded } }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).toContain(triggerActiveClass); + }, + ); + + it.each` + job | jobName | expanded | link + ${mockJob} | ${mockJob.name} | ${false} | ${true} + ${mockJobWithoutDetails} | ${mockJobWithoutDetails.name} | ${false} | ${false} + `( + `trigger job should not be highlighted when downstream is not expanded`, + ({ job, jobName, expanded, link }) => { + createWrapper({ job, pipelineExpanded: { jobName, expanded } }); + const findJobEl = link ? findJobWithLink : findJobWithoutLink; + + expect(findJobEl().classes()).not.toContain(triggerActiveClass); + }, + ); + }); }); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 59121c54ff3..8e65f0d4f71 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlButton } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; @@ -16,10 +16,18 @@ describe('Linked pipeline', () => { const findButton = () => wrapper.find(GlButton); const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findPipelineLink = () => wrapper.find('[data-testid="pipelineLink"]'); + const findExpandButton = () => wrapper.find('[data-testid="expandPipelineButton"]'); - const createWrapper = propsData => { + const createWrapper = (propsData, data = []) => { wrapper = mount(LinkedPipelineComponent, { propsData, + data() { + return { + ...data, + }; + }, }); }; @@ -39,7 +47,7 @@ describe('Linked pipeline', () => { }); it('should render a list item as the containing element', () => { - expect(wrapper.is('li')).toBe(true); + expect(wrapper.element.tagName).toBe('LI'); }); it('should render a button', () => { @@ -76,7 +84,7 @@ describe('Linked pipeline', () => { }); it('should render the tooltip text as the title attribute', () => { - const titleAttr = findButton().attributes('title'); + const titleAttr = findLinkedPipeline().attributes('title'); expect(titleAttr).toContain(mockPipeline.project.name); expect(titleAttr).toContain(mockPipeline.details.status.label); @@ -117,6 +125,56 @@ describe('Linked pipeline', () => { createWrapper(upstreamProps); expect(findPipelineLabel().exists()).toBe(true); }); + + it('downstream pipeline should contain the correct link', () => { + createWrapper(downstreamProps); + expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path); + }); + + it('upstream pipeline should contain the correct link', () => { + createWrapper(upstreamProps); + expect(findPipelineLink().attributes('href')).toBe(mockData.triggered_by.path); + }); + + it.each` + presentClass | missingClass + ${'gl-right-0'} | ${'gl-left-0'} + ${'gl-border-l-1!'} | ${'gl-border-r-1!'} + `( + 'pipeline expand button should be postioned right when child pipeline', + ({ presentClass, missingClass }) => { + createWrapper(downstreamProps); + expect(findExpandButton().classes()).toContain(presentClass); + expect(findExpandButton().classes()).not.toContain(missingClass); + }, + ); + + it.each` + presentClass | missingClass + ${'gl-left-0'} | ${'gl-right-0'} + ${'gl-border-r-1!'} | ${'gl-border-l-1!'} + `( + 'pipeline expand button should be postioned left when parent pipeline', + ({ presentClass, missingClass }) => { + createWrapper(upstreamProps); + expect(findExpandButton().classes()).toContain(presentClass); + expect(findExpandButton().classes()).not.toContain(missingClass); + }, + ); + + it.each` + pipelineType | anglePosition | expanded + ${downstreamProps} | ${'angle-right'} | ${false} + ${downstreamProps} | ${'angle-left'} | ${true} + ${upstreamProps} | ${'angle-left'} | ${false} + ${upstreamProps} | ${'angle-right'} | ${true} + `( + '$pipelineType.columnTitle pipeline button icon should be $anglePosition if expanded state is $expanded', + ({ pipelineType, anglePosition, expanded }) => { + createWrapper(pipelineType, { expanded }); + expect(findExpandButton().props('icon')).toBe(anglePosition); + }, + ); }); describe('when isLoading is true', () => { @@ -130,8 +188,8 @@ describe('Linked pipeline', () => { createWrapper(props); }); - it('sets the loading prop to true', () => { - expect(findButton().props('loading')).toBe(true); + it('loading icon is visible', () => { + expect(findLoadingIcon().exists()).toBe(true); }); }); @@ -172,5 +230,10 @@ describe('Linked pipeline', () => { findLinkedPipeline().trigger('mouseleave'); expect(wrapper.emitted().downstreamHovered).toStrictEqual([['']]); }); + + it('should emit pipelineExpanded with job name and expanded state on click', () => { + findExpandButton().trigger('click'); + expect(wrapper.emitted().pipelineExpandToggle).toStrictEqual([['trigger_job', true]]); + }); }); }); diff --git a/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js b/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js new file mode 100644 index 00000000000..fea42350959 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_graph/gitlab_ci_yaml_visualization_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTab } from '@gitlab/ui'; +import { yamlString } from './mock_data'; +import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import GitlabCiYamlVisualization from '~/pipelines/components/pipeline_graph/gitlab_ci_yaml_visualization.vue'; + +describe('gitlab yaml visualization component', () => { + const defaultProps = { blobData: yamlString }; + let wrapper; + + const createComponent = props => { + return shallowMount(GitlabCiYamlVisualization, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findGlTabComponents = () => wrapper.findAll(GlTab); + const findPipelineGraph = () => wrapper.find(PipelineGraph); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('tabs component', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('renders the file and visualization tabs', () => { + expect(findGlTabComponents()).toHaveLength(2); + }); + }); + + describe('graph component', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('is hidden by default', () => { + expect(findPipelineGraph().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_graph/mock_data.js b/spec/frontend/pipelines/pipeline_graph/mock_data.js new file mode 100644 index 00000000000..5a5d6c021a6 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_graph/mock_data.js @@ -0,0 +1,80 @@ +export const yamlString = `stages: +- empty +- build +- test +- deploy +- final + +include: +- template: 'Workflows/MergeRequest-Pipelines.gitlab-ci.yml' + +build_a: + stage: build + script: echo hello +build_b: + stage: build + script: echo hello +build_c: + stage: build + script: echo hello +build_d: + stage: Queen + script: echo hello + +test_a: + stage: test + script: ls + needs: [build_a, build_b, build_c] +test_b: + stage: test + script: ls + needs: [build_a, build_b, build_d] +test_c: + stage: test + script: ls + needs: [build_a, build_b, build_c] + +deploy_a: + stage: deploy + script: echo hello +`; + +export const pipelineData = { + stages: [ + { + name: 'build', + groups: [], + }, + { + name: 'build', + groups: [ + { + name: 'build_1', + jobs: [{ script: 'echo hello', stage: 'build' }], + }, + ], + }, + { + name: 'test', + groups: [ + { + name: 'test_1', + jobs: [{ script: 'yarn test', stage: 'test' }], + }, + { + name: 'test_2', + jobs: [{ script: 'yarn karma', stage: 'test' }], + }, + ], + }, + { + name: 'deploy', + groups: [ + { + name: 'deploy_1', + jobs: [{ script: 'yarn magick', stage: 'deploy' }], + }, + ], + }, + ], +}; diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js new file mode 100644 index 00000000000..30e192e5726 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import { pipelineData } from './mock_data'; +import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; +import StagePill from '~/pipelines/components/pipeline_graph/stage_pill.vue'; +import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; + +describe('pipeline graph component', () => { + const defaultProps = { pipelineData }; + let wrapper; + + const createComponent = props => { + return shallowMount(PipelineGraph, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findAllStagePills = () => wrapper.findAll(StagePill); + const findAllJobPills = () => wrapper.findAll(JobPill); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('with no data', () => { + beforeEach(() => { + wrapper = createComponent({ pipelineData: {} }); + }); + + it('renders an empty section', () => { + expect(wrapper.text()).toContain('No content to show'); + expect(findAllStagePills()).toHaveLength(0); + expect(findAllJobPills()).toHaveLength(0); + }); + }); + + describe('with data', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + it('renders the right number of stage pills', () => { + const expectedStagesLength = pipelineData.stages.length; + + expect(findAllStagePills()).toHaveLength(expectedStagesLength); + }); + + it('renders the right number of job pills', () => { + // We count the number of jobs in the mock data + const expectedJobsLength = pipelineData.stages.reduce((acc, val) => { + return acc + val.groups.length; + }, 0); + + expect(findAllJobPills()).toHaveLength(expectedJobsLength); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_graph/utils_spec.js b/spec/frontend/pipelines/pipeline_graph/utils_spec.js new file mode 100644 index 00000000000..dd85c8c2bd0 --- /dev/null +++ b/spec/frontend/pipelines/pipeline_graph/utils_spec.js @@ -0,0 +1,150 @@ +import { preparePipelineGraphData } from '~/pipelines/utils'; + +describe('preparePipelineGraphData', () => { + const emptyResponse = { stages: [] }; + const jobName1 = 'build_1'; + const jobName2 = 'build_2'; + const jobName3 = 'test_1'; + const jobName4 = 'deploy_1'; + const job1 = { [jobName1]: { script: 'echo hello', stage: 'build' } }; + const job2 = { [jobName2]: { script: 'echo build', stage: 'build' } }; + const job3 = { [jobName3]: { script: 'echo test', stage: 'test' } }; + const job4 = { [jobName4]: { script: 'echo deploy', stage: 'deploy' } }; + + describe('returns an object with an empty array of stages if', () => { + it('no data is passed', () => { + expect(preparePipelineGraphData({})).toEqual(emptyResponse); + }); + + it('no stages are found', () => { + expect(preparePipelineGraphData({ includes: 'template/myTemplate.gitlab-ci.yml' })).toEqual( + emptyResponse, + ); + }); + }); + + describe('returns the correct array of stages', () => { + it('when multiple jobs are in the same stage', () => { + const expectedData = { + stages: [ + { + name: job1[jobName1].stage, + groups: [ + { + name: jobName1, + jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], + }, + { + name: jobName2, + jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }], + }, + ], + }, + ], + }; + + expect(preparePipelineGraphData({ ...job1, ...job2 })).toEqual(expectedData); + }); + + it('when stages are defined by the user', () => { + const userDefinedStage = 'myStage'; + const userDefinedStage2 = 'myStage2'; + + const expectedData = { + stages: [ + { + name: userDefinedStage, + groups: [], + }, + { + name: userDefinedStage2, + groups: [], + }, + ], + }; + + expect(preparePipelineGraphData({ stages: [userDefinedStage, userDefinedStage2] })).toEqual( + expectedData, + ); + }); + + it('by combining user defined stage and job stages, it preserves user defined order', () => { + const userDefinedStage = 'myStage'; + const userDefinedStageThatOverlaps = 'deploy'; + + const expectedData = { + stages: [ + { + name: userDefinedStage, + groups: [], + }, + { + name: job4[jobName4].stage, + groups: [ + { + name: jobName4, + jobs: [{ script: job4[jobName4].script, stage: job4[jobName4].stage }], + }, + ], + }, + { + name: job1[jobName1].stage, + groups: [ + { + name: jobName1, + jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], + }, + { + name: jobName2, + jobs: [{ script: job2[jobName2].script, stage: job2[jobName2].stage }], + }, + ], + }, + { + name: job3[jobName3].stage, + groups: [ + { + name: jobName3, + jobs: [{ script: job3[jobName3].script, stage: job3[jobName3].stage }], + }, + ], + }, + ], + }; + + expect( + preparePipelineGraphData({ + stages: [userDefinedStage, userDefinedStageThatOverlaps], + ...job1, + ...job2, + ...job3, + ...job4, + }), + ).toEqual(expectedData); + }); + + it('with only unique values', () => { + const expectedData = { + stages: [ + { + name: job1[jobName1].stage, + groups: [ + { + name: jobName1, + jobs: [{ script: job1[jobName1].script, stage: job1[jobName1].stage }], + }, + ], + }, + ], + }; + + expect( + preparePipelineGraphData({ + stages: ['build'], + ...job1, + ...job1, + }), + ).toEqual(expectedData); + }); + }); +}); diff --git a/spec/frontend/pipelines/pipeline_triggerer_spec.js b/spec/frontend/pipelines/pipeline_triggerer_spec.js index 6fd9a143d82..ad8136890e6 100644 --- a/spec/frontend/pipelines/pipeline_triggerer_spec.js +++ b/spec/frontend/pipelines/pipeline_triggerer_spec.js @@ -36,7 +36,7 @@ describe('Pipelines Triggerer', () => { }); it('should render a table cell', () => { - expect(wrapper.contains('.table-section')).toBe(true); + expect(wrapper.find('.table-section').exists()).toBe(true); }); it('should pass triggerer information when triggerer is provided', () => { diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index cce4c2dfa7b..071a2b24889 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'spec/test_constants'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue'; @@ -19,7 +19,7 @@ describe('Pipelines Actions dropdown', () => { }); }; - const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedButton); + const findAllDropdownItems = () => wrapper.findAll(GlButton); const findAllCountdowns = () => wrapper.findAll(GlCountdown); beforeEach(() => { @@ -66,7 +66,7 @@ describe('Pipelines Actions dropdown', () => { it('makes a request and toggles the loading state', () => { mock.onPost(mockActions.path).reply(200); - wrapper.find(GlDeprecatedButton).vm.$emit('click'); + wrapper.find(GlButton).vm.$emit('click'); expect(wrapper.vm.isLoading).toBe(true); diff --git a/spec/frontend/pipelines/test_reports/stores/getters_spec.js b/spec/frontend/pipelines/test_reports/stores/getters_spec.js index ca9ebb54138..58e8065033f 100644 --- a/spec/frontend/pipelines/test_reports/stores/getters_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/getters_spec.js @@ -1,6 +1,6 @@ import { getJSONFixture } from 'helpers/fixtures'; import * as getters from '~/pipelines/stores/test_reports/getters'; -import { iconForTestStatus } from '~/pipelines/stores/test_reports/utils'; +import { iconForTestStatus, formattedTime } from '~/pipelines/stores/test_reports/utils'; describe('Getters TestReports Store', () => { let state; @@ -34,7 +34,7 @@ describe('Getters TestReports Store', () => { const suites = getters.getTestSuites(state); const expected = testReports.test_suites.map(x => ({ ...x, - formattedTime: '00:00:00', + formattedTime: formattedTime(x.total_time), })); expect(suites).toEqual(expected); @@ -65,7 +65,7 @@ describe('Getters TestReports Store', () => { const cases = getters.getSuiteTests(state); const expected = testReports.test_suites[0].test_cases.map(x => ({ ...x, - formattedTime: '00:00:00', + formattedTime: formattedTime(x.execution_time), icon: iconForTestStatus(x.status), })); diff --git a/spec/frontend/pipelines/test_reports/stores/utils_spec.js b/spec/frontend/pipelines/test_reports/stores/utils_spec.js new file mode 100644 index 00000000000..7e632d099fc --- /dev/null +++ b/spec/frontend/pipelines/test_reports/stores/utils_spec.js @@ -0,0 +1,26 @@ +import { formattedTime } from '~/pipelines/stores/test_reports/utils'; + +describe('Test reports utils', () => { + describe('formattedTime', () => { + describe('when time is smaller than a second', () => { + it('should return time in milliseconds fixed to 2 decimals', () => { + const result = formattedTime(0.4815162342); + expect(result).toBe('481.52ms'); + }); + }); + + describe('when time is equal to a second', () => { + it('should return time in seconds fixed to 2 decimals', () => { + const result = formattedTime(1); + expect(result).toBe('1.00s'); + }); + }); + + describe('when time is greater than a second', () => { + it('should return time in seconds fixed to 2 decimals', () => { + const result = formattedTime(4.815162342); + expect(result).toBe('4.82s'); + }); + }); + }); +}); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index a709edf5184..c8ab18b9086 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -1,4 +1,5 @@ import Vuex from 'vuex'; +import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; import TestReports from '~/pipelines/components/test_reports/test_reports.vue'; @@ -15,9 +16,9 @@ describe('Test reports app', () => { const testReports = getJSONFixture('pipelines/test_report.json'); - const loadingSpinner = () => wrapper.find('.js-loading-spinner'); - const testsDetail = () => wrapper.find('.js-tests-detail'); - const noTestsToShow = () => wrapper.find('.js-no-tests-to-show'); + const loadingSpinner = () => wrapper.find(GlLoadingIcon); + const testsDetail = () => wrapper.find('[data-testid="tests-detail"]'); + const noTestsToShow = () => wrapper.find('[data-testid="no-tests-to-show"]'); const testSummary = () => wrapper.find(TestSummary); const testSummaryTable = () => wrapper.find(TestSummaryTable); @@ -88,6 +89,10 @@ describe('Test reports app', () => { expect(wrapper.vm.testReports).toBeTruthy(); expect(wrapper.vm.showTests).toBeTruthy(); }); + + it('shows tests details', () => { + expect(testsDetail().exists()).toBe(true); + }); }); describe('when a suite is clicked', () => { diff --git a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js index 3a4aa94571e..2feb6aa5799 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -23,8 +23,6 @@ describe('Test reports suite table', () => { const noCasesMessage = () => wrapper.find('.js-no-test-cases'); const allCaseRows = () => wrapper.findAll('.js-case-row'); const findCaseRowAtIndex = index => wrapper.findAll('.js-case-row').at(index); - const allCaseNames = () => - wrapper.findAll('[data-testid="caseName"]').wrappers.map(el => el.attributes('text')); const findIconForRow = (row, status) => row.find(`.ci-status-icon-${status}`); const createComponent = (suite = testSuite) => { @@ -63,16 +61,6 @@ describe('Test reports suite table', () => { expect(allCaseRows().length).toBe(testCases.length); }); - it('renders the failed tests first, skipped tests next, then successful tests', () => { - const expectedCaseOrder = [ - ...testCases.filter(x => x.status === TestStatus.FAILED), - ...testCases.filter(x => x.status === TestStatus.SKIPPED), - ...testCases.filter(x => x.status === TestStatus.SUCCESS), - ].map(x => x.name); - - expect(allCaseNames()).toEqual(expectedCaseOrder); - }); - it('renders the correct icon for each status', () => { const failedTest = testCases.findIndex(x => x.status === TestStatus.FAILED); const skippedTest = testCases.findIndex(x => x.status === TestStatus.SKIPPED); diff --git a/spec/frontend/pipelines/test_reports/test_summary_spec.js b/spec/frontend/pipelines/test_reports/test_summary_spec.js index 79be6c168cf..dc5af7b160c 100644 --- a/spec/frontend/pipelines/test_reports/test_summary_spec.js +++ b/spec/frontend/pipelines/test_reports/test_summary_spec.js @@ -1,6 +1,7 @@ import { mount } from '@vue/test-utils'; import { getJSONFixture } from 'helpers/fixtures'; import Summary from '~/pipelines/components/test_reports/test_summary.vue'; +import { formattedTime } from '~/pipelines/stores/test_reports/utils'; describe('Test reports summary', () => { let wrapper; @@ -76,7 +77,7 @@ describe('Test reports summary', () => { }); it('displays the correctly formatted duration', () => { - expect(duration().text()).toBe('00:00:00'); + expect(duration().text()).toBe(formattedTime(testSuite.total_time)); }); }); diff --git a/spec/frontend/pipelines/time_ago_spec.js b/spec/frontend/pipelines/time_ago_spec.js index 04934fb93b0..b7bc8d08a0f 100644 --- a/spec/frontend/pipelines/time_ago_spec.js +++ b/spec/frontend/pipelines/time_ago_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import TimeAgo from '~/pipelines/components/pipelines_list/time_ago.vue'; describe('Timeago component', () => { @@ -22,14 +23,19 @@ describe('Timeago component', () => { wrapper = null; }); + const duration = () => wrapper.find('.duration'); + const finishedAt = () => wrapper.find('.finished-at'); + describe('with duration', () => { beforeEach(() => { createComponent({ duration: 10, finishedTime: '' }); }); it('should render duration and timer svg', () => { - expect(wrapper.find('.duration').exists()).toBe(true); - expect(wrapper.find('.duration svg').exists()).toBe(true); + const icon = duration().find(GlIcon); + + expect(duration().exists()).toBe(true); + expect(icon.props('name')).toBe('timer'); }); }); @@ -39,7 +45,7 @@ describe('Timeago component', () => { }); it('should not render duration and timer svg', () => { - expect(wrapper.find('.duration').exists()).toBe(false); + expect(duration().exists()).toBe(false); }); }); @@ -49,9 +55,12 @@ describe('Timeago component', () => { }); it('should render time and calendar icon', () => { - expect(wrapper.find('.finished-at').exists()).toBe(true); - expect(wrapper.find('.finished-at i.fa-calendar').exists()).toBe(true); - expect(wrapper.find('.finished-at time').exists()).toBe(true); + const icon = finishedAt().find(GlIcon); + const time = finishedAt().find('time'); + + expect(finishedAt().exists()).toBe(true); + expect(icon.props('name')).toBe('calendar'); + expect(time.exists()).toBe(true); }); }); @@ -61,7 +70,7 @@ describe('Timeago component', () => { }); it('should not render time and calendar icon', () => { - expect(wrapper.find('.finished-at').exists()).toBe(false); + expect(finishedAt().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index 096e4cd97f6..b53955ab743 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -5,16 +5,17 @@ import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pi describe('Pipeline Status Token', () => { let wrapper; - const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); - const findAllGlIcons = () => wrapper.findAll(GlIcon); - const stubs = { GlFilteredSearchToken: { + props: GlFilteredSearchToken.props, template: `<div><slot name="suggestions"></slot></div>`, }, }; + const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + const findAllGlIcons = () => wrapper.findAll(GlIcon); + const defaultProps = { config: { type: 'status', @@ -27,12 +28,12 @@ describe('Pipeline Status Token', () => { }, }; - const createComponent = options => { + const createComponent = () => { wrapper = shallowMount(PipelineStatusToken, { propsData: { ...defaultProps, }, - ...options, + stubs, }); }; @@ -50,10 +51,6 @@ describe('Pipeline Status Token', () => { }); describe('shows statuses correctly', () => { - beforeEach(() => { - createComponent({ stubs }); - }); - it('renders all pipeline statuses available', () => { expect(findAllFilteredSearchSuggestions()).toHaveLength(wrapper.vm.statuses.length); expect(findAllGlIcons()).toHaveLength(wrapper.vm.statuses.length); diff --git a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js index c95d2ea1b7b..9363944a719 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -7,16 +7,17 @@ import { users } from '../mock_data'; describe('Pipeline Trigger Author Token', () => { let wrapper; - const findFilteredSearchToken = () => wrapper.find(GlFilteredSearchToken); - const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); - const findLoadingIcon = () => wrapper.find(GlLoadingIcon); - const stubs = { GlFilteredSearchToken: { + props: GlFilteredSearchToken.props, template: `<div><slot name="suggestions"></slot></div>`, }, }; + const findFilteredSearchToken = () => wrapper.find(stubs.GlFilteredSearchToken); + const findAllFilteredSearchSuggestions = () => wrapper.findAll(GlFilteredSearchSuggestion); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const defaultProps = { config: { type: 'username', @@ -31,7 +32,7 @@ describe('Pipeline Trigger Author Token', () => { }, }; - const createComponent = (options, data) => { + const createComponent = data => { wrapper = shallowMount(PipelineTriggerAuthorToken, { propsData: { ...defaultProps, @@ -41,7 +42,7 @@ describe('Pipeline Trigger Author Token', () => { ...data, }; }, - ...options, + stubs, }); }; @@ -69,13 +70,13 @@ describe('Pipeline Trigger Author Token', () => { describe('displays loading icon correctly', () => { it('shows loading icon', () => { - createComponent({ stubs }, { loading: true }); + createComponent({ loading: true }); expect(findLoadingIcon().exists()).toBe(true); }); it('does not show loading icon', () => { - createComponent({ stubs }, { loading: false }); + createComponent({ loading: false }); expect(findLoadingIcon().exists()).toBe(false); }); @@ -85,22 +86,17 @@ describe('Pipeline Trigger Author Token', () => { beforeEach(() => {}); it('renders all trigger authors', () => { - createComponent({ stubs }, { users, loading: false }); + createComponent({ users, loading: false }); // should have length of all users plus the static 'Any' option expect(findAllFilteredSearchSuggestions()).toHaveLength(users.length + 1); }); it('renders only the trigger author searched for', () => { - createComponent( - { stubs }, - { - users: [ - { name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }, - ], - loading: false, - }, - ); + createComponent({ + users: [{ name: 'Arnold', username: 'admin', state: 'active', avatar_url: 'avatar-link' }], + loading: false, + }); expect(findAllFilteredSearchSuggestions()).toHaveLength(2); }); diff --git a/spec/frontend/profile/account/components/delete_account_modal_spec.js b/spec/frontend/profile/account/components/delete_account_modal_spec.js index 4da82152818..7834456f7c4 100644 --- a/spec/frontend/profile/account/components/delete_account_modal_spec.js +++ b/spec/frontend/profile/account/components/delete_account_modal_spec.js @@ -1,21 +1,49 @@ import Vue from 'vue'; import { TEST_HOST } from 'helpers/test_constants'; -import mountComponent from 'helpers/vue_mount_component_helper'; +import { merge } from 'lodash'; +import { mount } from '@vue/test-utils'; import deleteAccountModal from '~/profile/account/components/delete_account_modal.vue'; +const GlModalStub = { + name: 'gl-modal-stub', + template: ` + <div> + <slot></slot> + </div> + `, +}; + describe('DeleteAccountModal component', () => { const actionUrl = `${TEST_HOST}/delete/user`; const username = 'hasnoname'; - let Component; + let wrapper; let vm; - beforeEach(() => { - Component = Vue.extend(deleteAccountModal); - }); + const createWrapper = (options = {}) => { + wrapper = mount( + deleteAccountModal, + merge( + {}, + { + propsData: { + actionUrl, + username, + }, + stubs: { + GlModal: GlModalStub, + }, + }, + options, + ), + ); + vm = wrapper.vm; + }; afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + wrapper = null; + vm = null; }); const findElements = () => { @@ -23,16 +51,16 @@ describe('DeleteAccountModal component', () => { return { form: vm.$refs.form, input: vm.$el.querySelector(`[name="${confirmation}"]`), - submitButton: vm.$el.querySelector('.btn-danger'), }; }; + const findModal = () => wrapper.find(GlModalStub); describe('with password confirmation', () => { beforeEach(done => { - vm = mountComponent(Component, { - actionUrl, - confirmWithPassword: true, - username, + createWrapper({ + propsData: { + confirmWithPassword: true, + }, }); vm.isOpen = true; @@ -43,7 +71,7 @@ describe('DeleteAccountModal component', () => { }); it('does not accept empty password', done => { - const { form, input, submitButton } = findElements(); + const { form, input } = findElements(); jest.spyOn(form, 'submit').mockImplementation(() => {}); input.value = ''; input.dispatchEvent(new Event('input')); @@ -51,8 +79,8 @@ describe('DeleteAccountModal component', () => { Vue.nextTick() .then(() => { expect(vm.enteredPassword).toBe(input.value); - expect(submitButton).toHaveAttr('disabled', 'disabled'); - submitButton.click(); + expect(findModal().attributes('ok-disabled')).toBe('true'); + findModal().vm.$emit('primary'); expect(form.submit).not.toHaveBeenCalled(); }) @@ -61,7 +89,7 @@ describe('DeleteAccountModal component', () => { }); it('submits form with password', done => { - const { form, input, submitButton } = findElements(); + const { form, input } = findElements(); jest.spyOn(form, 'submit').mockImplementation(() => {}); input.value = 'anything'; input.dispatchEvent(new Event('input')); @@ -69,8 +97,8 @@ describe('DeleteAccountModal component', () => { Vue.nextTick() .then(() => { expect(vm.enteredPassword).toBe(input.value); - expect(submitButton).not.toHaveAttr('disabled', 'disabled'); - submitButton.click(); + expect(findModal().attributes('ok-disabled')).toBeUndefined(); + findModal().vm.$emit('primary'); expect(form.submit).toHaveBeenCalled(); }) @@ -81,10 +109,10 @@ describe('DeleteAccountModal component', () => { describe('with username confirmation', () => { beforeEach(done => { - vm = mountComponent(Component, { - actionUrl, - confirmWithPassword: false, - username, + createWrapper({ + propsData: { + confirmWithPassword: false, + }, }); vm.isOpen = true; @@ -95,7 +123,7 @@ describe('DeleteAccountModal component', () => { }); it('does not accept wrong username', done => { - const { form, input, submitButton } = findElements(); + const { form, input } = findElements(); jest.spyOn(form, 'submit').mockImplementation(() => {}); input.value = 'this is wrong'; input.dispatchEvent(new Event('input')); @@ -103,8 +131,8 @@ describe('DeleteAccountModal component', () => { Vue.nextTick() .then(() => { expect(vm.enteredUsername).toBe(input.value); - expect(submitButton).toHaveAttr('disabled', 'disabled'); - submitButton.click(); + expect(findModal().attributes('ok-disabled')).toBe('true'); + findModal().vm.$emit('primary'); expect(form.submit).not.toHaveBeenCalled(); }) @@ -113,7 +141,7 @@ describe('DeleteAccountModal component', () => { }); it('submits form with correct username', done => { - const { form, input, submitButton } = findElements(); + const { form, input } = findElements(); jest.spyOn(form, 'submit').mockImplementation(() => {}); input.value = username; input.dispatchEvent(new Event('input')); @@ -121,8 +149,8 @@ describe('DeleteAccountModal component', () => { Vue.nextTick() .then(() => { expect(vm.enteredUsername).toBe(input.value); - expect(submitButton).not.toHaveAttr('disabled', 'disabled'); - submitButton.click(); + expect(findModal().attributes('ok-disabled')).toBeUndefined(); + findModal().vm.$emit('primary'); expect(form.submit).toHaveBeenCalled(); }) diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index d6fac6f5f79..68c285a4097 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -1,11 +1,6 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import { - GlNewDropdown, - GlNewDropdownHeader, - GlSearchBoxByType, - GlNewDropdownItem, -} from '@gitlab/ui'; +import { GlDropdown, GlDropdownSectionHeader, GlSearchBoxByType, GlDropdownItem } from '@gitlab/ui'; import * as urlUtility from '~/lib/utils/url_utility'; import AuthorSelect from '~/projects/commits/components/author_select.vue'; import { createStore } from '~/projects/commits/store'; @@ -63,10 +58,10 @@ describe('Author Select', () => { }); const findDropdownContainer = () => wrapper.find({ ref: 'dropdownContainer' }); - const findDropdown = () => wrapper.find(GlNewDropdown); - const findDropdownHeader = () => wrapper.find(GlNewDropdownHeader); + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownHeader = () => wrapper.find(GlDropdownSectionHeader); const findSearchBox = () => wrapper.find(GlSearchBoxByType); - const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem); + const findDropdownItems = () => wrapper.findAll(GlDropdownItem); describe('user is searching via "filter by commit message"', () => { it('disables dropdown container', () => { @@ -133,11 +128,7 @@ describe('Author Select', () => { const authorName = 'lorem'; findSearchBox().vm.$emit('input', authorName); - expect(store.actions.fetchAuthors).toHaveBeenCalledWith( - expect.anything(), - authorName, - undefined, - ); + expect(store.actions.fetchAuthors).toHaveBeenCalledWith(expect.anything(), authorName); }); }); diff --git a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap index 44220bdef64..455467e7b29 100644 --- a/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -51,12 +51,12 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` variant="danger" > <gl-sprintf-stub - message="Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its respositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc." + message="Once a project is permanently deleted it %{strongStart}cannot be recovered%{strongEnd}. Permanently deleting this project will %{strongStart}immediately delete%{strongEnd} its repositories and %{strongStart}all related resources%{strongEnd} including issues, merge requests etc." /> </gl-alert-stub> <p> - This action cannot be undone. You will lose the project's respository and all conent: issues, merge requests, etc. + This action cannot be undone. You will lose the project's repository and all content: issues, merge requests, etc. </p> <p @@ -66,7 +66,9 @@ exports[`Project remove modal initialized matches the snapshot 1`] = ` </p> <p> - <code> + <code + class="gl-white-space-pre-wrap" + > foo </code> </p> diff --git a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap index a43acc8c002..692b8f6cf52 100644 --- a/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap +++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap @@ -55,7 +55,9 @@ exports[`Project remove modal intialized matches the snapshot 1`] = ` </p> <p> - <code> + <code + class="gl-white-space-pre-wrap" + > foo </code> </p> diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js index 6d323b0408b..3b375c5610f 100644 --- a/spec/frontend/projects/settings/access_dropdown_spec.js +++ b/spec/frontend/projects/settings/access_dropdown_spec.js @@ -1,5 +1,4 @@ import $ from 'jquery'; -import '~/gl_dropdown'; import AccessDropdown from '~/projects/settings/access_dropdown'; import { LEVEL_TYPES } from '~/projects/settings/constants'; diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js index 4c873bdfd60..0f3b699f6b2 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_root_spec.js @@ -195,7 +195,7 @@ describe('ServiceDeskRoot', () => { .$nextTick() .then(waitForPromises) .then(() => { - expect(wrapper.html()).toContain('Template was successfully saved.'); + expect(wrapper.html()).toContain('Changes were successfully made.'); }); }); diff --git a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js index 7fe310aa400..cb46751f66a 100644 --- a/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js +++ b/spec/frontend/projects/settings_service_desk/components/service_desk_setting_spec.js @@ -26,16 +26,16 @@ describe('ServiceDeskSetting', () => { }); it('should see activation checkbox', () => { - expect(wrapper.contains('#service-desk-checkbox')).toBe(true); + expect(wrapper.find('#service-desk-checkbox').exists()).toBe(true); }); it('should see main panel with the email info', () => { - expect(wrapper.contains('#incoming-email-describer')).toBe(true); + expect(wrapper.find('#incoming-email-describer').exists()).toBe(true); }); it('should see loading spinner and not the incoming email', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.contains('.incoming-email')).toBe(false); + expect(wrapper.find('.incoming-email').exists()).toBe(false); }); }); }); @@ -78,7 +78,7 @@ describe('ServiceDeskSetting', () => { }); it('renders a copy to clipboard button', () => { - expect(wrapper.contains('.qa-clipboard-button')).toBe(true); + expect(wrapper.find('.qa-clipboard-button').exists()).toBe(true); expect(wrapper.find('.qa-clipboard-button').element.dataset.clipboardText).toBe( incomingEmail, ); @@ -93,7 +93,7 @@ describe('ServiceDeskSetting', () => { }, }); - expect(wrapper.contains('#service-desk-template-select')).toBe(true); + expect(wrapper.find('#service-desk-template-select').exists()).toBe(true); }); it('renders a dropdown with a default value of ""', () => { @@ -163,7 +163,7 @@ describe('ServiceDeskSetting', () => { }, }); - expect(wrapper.find('button.btn-success').text()).toContain('Save template'); + expect(wrapper.find('button.btn-success').text()).toContain('Save changes'); }); it('emits a save event with the chosen template when the save button is clicked', () => { @@ -202,15 +202,15 @@ describe('ServiceDeskSetting', () => { }); it('does not render email panel', () => { - expect(wrapper.contains('#incoming-email-describer')).toBe(false); + expect(wrapper.find('#incoming-email-describer').exists()).toBe(false); }); it('does not render template dropdown', () => { - expect(wrapper.contains('#service-desk-template-select')).toBe(false); + expect(wrapper.find('#service-desk-template-select').exists()).toBe(false); }); it('does not render template save button', () => { - expect(wrapper.contains('button.btn-success')).toBe(false); + expect(wrapper.find('button.btn-success').exists()).toBe(false); }); it('emits an event to turn on Service Desk when the toggle is clicked', () => { diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 1556f5b19dc..00b1d5cfbe2 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -2,9 +2,10 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { GlLoadingIcon, GlSearchBoxByType, GlNewDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlSearchBoxByType, GlDropdownItem, GlIcon } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; import { sprintf } from '~/locale'; +import { ENTER_KEY } from '~/lib/utils/keys'; import RefSelector from '~/ref/components/ref_selector.vue'; import { X_TOTAL_HEADER, DEFAULT_I18N } from '~/ref/constants'; import createStore from '~/ref/stores/'; @@ -83,16 +84,18 @@ describe('Ref selector component', () => { const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findSearchBox = () => wrapper.find(GlSearchBoxByType); + const findBranchesSection = () => wrapper.find('[data-testid="branches-section"]'); - const findBranchDropdownItems = () => findBranchesSection().findAll(GlNewDropdownItem); + const findBranchDropdownItems = () => findBranchesSection().findAll(GlDropdownItem); const findFirstBranchDropdownItem = () => findBranchDropdownItems().at(0); const findTagsSection = () => wrapper.find('[data-testid="tags-section"]'); - const findTagDropdownItems = () => findTagsSection().findAll(GlNewDropdownItem); + const findTagDropdownItems = () => findTagsSection().findAll(GlDropdownItem); const findFirstTagDropdownItem = () => findTagDropdownItems().at(0); const findCommitsSection = () => wrapper.find('[data-testid="commits-section"]'); - const findCommitDropdownItems = () => findCommitsSection().findAll(GlNewDropdownItem); + const findCommitDropdownItems = () => findCommitsSection().findAll(GlDropdownItem); const findFirstCommitDropdownItem = () => findCommitDropdownItems().at(0); // @@ -120,7 +123,7 @@ describe('Ref selector component', () => { // Convenience methods // const updateQuery = newQuery => { - wrapper.find(GlSearchBoxByType).vm.$emit('input', newQuery); + findSearchBox().vm.$emit('input', newQuery); }; const selectFirstBranch = () => { @@ -174,7 +177,7 @@ describe('Ref selector component', () => { return waitForRequests(); }); - it('adds the provided ID to the GlNewDropdown instance', () => { + it('adds the provided ID to the GlDropdown instance', () => { expect(wrapper.attributes().id).toBe(id); }); }); @@ -244,6 +247,23 @@ describe('Ref selector component', () => { }); }); + describe('when the Enter is pressed', () => { + beforeEach(() => { + createComponent(); + + return waitForRequests({ andClearMocks: true }); + }); + + it('requeries the endpoints when Enter is pressed', () => { + findSearchBox().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + + return waitForRequests().then(() => { + expect(branchesApiCallSpy).toHaveBeenCalledTimes(1); + expect(tagsApiCallSpy).toHaveBeenCalledTimes(1); + }); + }); + }); + describe('when no results are found', () => { beforeEach(() => { branchesApiCallSpy = jest.fn().mockReturnValue([200, [], { [X_TOTAL_HEADER]: '0' }]); diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js index bb0fe81117a..a79ca77a464 100644 --- a/spec/frontend/registry/explorer/components/delete_button_spec.js +++ b/spec/frontend/registry/explorer/components/delete_button_spec.js @@ -54,7 +54,6 @@ describe('delete_button', () => { mountComponent({ disabled: true }); expect(findButton().attributes()).toMatchObject({ 'aria-label': 'Foo title', - category: 'secondary', icon: 'remove', title: 'Foo title', variant: 'danger', diff --git a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js index cb31efa428f..fc93e9094c9 100644 --- a/spec/frontend/registry/explorer/components/details_page/details_header_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/details_header_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlSprintf } from '@gitlab/ui'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import component from '~/registry/explorer/components/details_page/details_header.vue'; import { DETAILS_PAGE_TITLE } from '~/registry/explorer/constants'; @@ -11,6 +12,7 @@ describe('Details Header', () => { propsData, stubs: { GlSprintf, + TitleArea, }, }); }; diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js index a21facefc97..ef22979ca7d 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -6,7 +6,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; import DeleteButton from '~/registry/explorer/components/delete_button.vue'; -import DetailsRow from '~/registry/shared/components/details_row.vue'; +import DetailsRow from '~/vue_shared/components/registry/details_row.vue'; import { REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js index 1f560753476..401202026bb 100644 --- a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -115,7 +115,6 @@ describe('Tags List', () => { // The list has only two tags and for some reasons .at(-1) does not work expect(rows.at(1).attributes()).toMatchObject({ - last: 'true', isdesktop: 'true', }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js index b0291de5f3c..b4471ab8122 100644 --- a/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/cli_commands_spec.js @@ -1,10 +1,10 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedDropdown, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { GlDeprecatedDropdown } from '@gitlab/ui'; import Tracking from '~/tracking'; import * as getters from '~/registry/explorer/stores/getters'; import QuickstartDropdown from '~/registry/explorer/components/list_page/cli_commands.vue'; -import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; import { QUICK_START, @@ -24,7 +24,7 @@ describe('cli_commands', () => { let store; const findDropdownButton = () => wrapper.find(GlDeprecatedDropdown); - const findFormGroups = () => wrapper.findAll(GlFormGroup); + const findCodeInstruction = () => wrapper.findAll(CodeInstruction); const mountComponent = () => { store = new Vuex.Store({ @@ -67,54 +67,29 @@ describe('cli_commands', () => { }); describe.each` - index | id | labelText | titleText | getter | trackedEvent - ${0} | ${'docker-login-btn'} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${'dockerLoginCommand'} | ${'click_copy_login'} - ${1} | ${'docker-build-btn'} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${'dockerBuildCommand'} | ${'click_copy_build'} - ${2} | ${'docker-push-btn'} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${'dockerPushCommand'} | ${'click_copy_push'} - `('form group at $index', ({ index, id, labelText, titleText, getter, trackedEvent }) => { - let formGroup; - - const findFormInputGroup = parent => parent.find(GlFormInputGroup); - const findClipboardButton = parent => parent.find(ClipboardButton); + index | labelText | titleText | getter | trackedEvent + ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${'dockerLoginCommand'} | ${'click_copy_login'} + ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${'dockerBuildCommand'} | ${'click_copy_build'} + ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${'dockerPushCommand'} | ${'click_copy_push'} + `('code instructions at $index', ({ index, labelText, titleText, getter, trackedEvent }) => { + let codeInstruction; beforeEach(() => { - formGroup = findFormGroups().at(index); + codeInstruction = findCodeInstruction().at(index); }); it('exists', () => { - expect(formGroup.exists()).toBe(true); - }); - - it(`has a label ${labelText}`, () => { - expect(formGroup.text()).toBe(labelText); - }); - - it(`contains a form input group with ${id} id and with value equal to ${getter} getter`, () => { - const formInputGroup = findFormInputGroup(formGroup); - expect(formInputGroup.exists()).toBe(true); - expect(formInputGroup.attributes('id')).toBe(id); - expect(formInputGroup.props('value')).toBe(store.getters[getter]); - }); - - it(`contains a clipboard button with title of ${titleText} and text equal to ${getter} getter`, () => { - const clipBoardButton = findClipboardButton(formGroup); - expect(clipBoardButton.exists()).toBe(true); - expect(clipBoardButton.props('title')).toBe(titleText); - expect(clipBoardButton.props('text')).toBe(store.getters[getter]); + expect(codeInstruction.exists()).toBe(true); }); - it('clipboard button tracks click event', () => { - const clipBoardButton = findClipboardButton(formGroup); - clipBoardButton.trigger('click'); - /* This expect to be called with first argument undefined so that - * the function internally can default to document.body.dataset.page - * https://docs.gitlab.com/ee/telemetry/frontend.html#tracking-within-vue-components - */ - expect(Tracking.event).toHaveBeenCalledWith( - undefined, - trackedEvent, - expect.objectContaining({ label: 'quickstart_dropdown' }), - ); + it(`has the correct props`, () => { + expect(codeInstruction.props()).toMatchObject({ + label: labelText, + instruction: store.getters[getter], + copyText: titleText, + trackingAction: trackedEvent, + trackingLabel: 'quickstart_dropdown', + }); }); }); }); diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index aaeaaf00748..c5b4b3fa5d8 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -3,7 +3,7 @@ import { GlIcon, GlSprintf } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; -import ListItem from '~/registry/explorer/components/list_item.vue'; +import ListItem from '~/vue_shared/components/registry/list_item.vue'; import DeleteButton from '~/registry/explorer/components/delete_button.vue'; import { ROW_SCHEDULED_FOR_DELETION, diff --git a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js index 7484fccbea7..7a27f8fa431 100644 --- a/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/registry_header_spec.js @@ -1,12 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import { GlSprintf, GlLink } from '@gitlab/ui'; import Component from '~/registry/explorer/components/list_page/registry_header.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { CONTAINER_REGISTRY_TITLE, LIST_INTRO_TEXT, EXPIRATION_POLICY_DISABLED_MESSAGE, EXPIRATION_POLICY_DISABLED_TEXT, - EXPIRATION_POLICY_WILL_RUN_IN, } from '~/registry/explorer/constants'; jest.mock('~/lib/utils/datetime_utility', () => ({ @@ -17,12 +17,10 @@ jest.mock('~/lib/utils/datetime_utility', () => ({ describe('registry_header', () => { let wrapper; - const findHeader = () => wrapper.find('[data-testid="header"]'); - const findTitle = () => wrapper.find('[data-testid="title"]'); + const findTitleArea = () => wrapper.find(TitleArea); const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]'); const findInfoArea = () => wrapper.find('[data-testid="info-area"]'); const findIntroText = () => wrapper.find('[data-testid="default-intro"]'); - const findSubHeader = () => wrapper.find('[data-testid="subheader"]'); const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]'); const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]'); const findDisabledExpirationPolicyMessage = () => @@ -32,10 +30,12 @@ describe('registry_header', () => { wrapper = shallowMount(Component, { stubs: { GlSprintf, + TitleArea, }, propsData, slots, }); + return wrapper.vm.$nextTick(); }; afterEach(() => { @@ -44,90 +44,80 @@ describe('registry_header', () => { }); describe('header', () => { - it('exists', () => { + it('has a title', () => { mountComponent(); - expect(findHeader().exists()).toBe(true); - }); - it('contains the title of the page', () => { - mountComponent(); - const title = findTitle(); - expect(title.exists()).toBe(true); - expect(title.text()).toBe(CONTAINER_REGISTRY_TITLE); + expect(findTitleArea().props('title')).toBe(CONTAINER_REGISTRY_TITLE); }); it('has a commands slot', () => { - mountComponent(null, { commands: 'baz' }); + mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' }); + expect(findCommandsSlot().text()).toBe('baz'); }); - }); - describe('subheader', () => { - describe('when there are no images', () => { - it('is hidden ', () => { - mountComponent(); - expect(findSubHeader().exists()).toBe(false); - }); - }); + describe('sub header parts', () => { + describe('images count', () => { + it('exists', async () => { + await mountComponent({ imagesCount: 1 }); - describe('when there are images', () => { - it('is visible', () => { - mountComponent({ imagesCount: 1 }); - expect(findSubHeader().exists()).toBe(true); - }); + expect(findImagesCountSubHeader().exists()).toBe(true); + }); + + it('when there is one image', async () => { + await mountComponent({ imagesCount: 1 }); - describe('sub header parts', () => { - describe('images count', () => { - it('exists', () => { - mountComponent({ imagesCount: 1 }); - expect(findImagesCountSubHeader().exists()).toBe(true); + expect(findImagesCountSubHeader().props()).toMatchObject({ + text: '1 Image repository', + icon: 'container-image', }); + }); + + it('when there is more than one image', async () => { + await mountComponent({ imagesCount: 3 }); + + expect(findImagesCountSubHeader().props('text')).toBe('3 Image repositories'); + }); + }); - it('when there is one image', () => { - mountComponent({ imagesCount: 1 }); - expect(findImagesCountSubHeader().text()).toMatchInterpolatedText('1 Image repository'); + describe('expiration policy', () => { + it('when is disabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: false }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, }); - it('when there is more than one image', () => { - mountComponent({ imagesCount: 3 }); - expect(findImagesCountSubHeader().text()).toMatchInterpolatedText( - '3 Image repositories', - ); + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.props()).toMatchObject({ + text: EXPIRATION_POLICY_DISABLED_TEXT, + icon: 'expire', + size: 'xl', }); }); - describe('expiration policy', () => { - it('when is disabled', () => { - mountComponent({ - expirationPolicy: { enabled: false }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - }); - const text = findExpirationPolicySubHeader(); - expect(text.exists()).toBe(true); - expect(text.text()).toMatchInterpolatedText(EXPIRATION_POLICY_DISABLED_TEXT); + it('when is enabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, }); - it('when is enabled', () => { - mountComponent({ - expirationPolicy: { enabled: true }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - }); - const text = findExpirationPolicySubHeader(); - expect(text.exists()).toBe(true); - expect(text.text()).toMatchInterpolatedText(EXPIRATION_POLICY_WILL_RUN_IN); - }); - it('when the expiration policy is completely disabled', () => { - mountComponent({ - expirationPolicy: { enabled: true }, - expirationPolicyHelpPagePath: 'foo', - imagesCount: 1, - hideExpirationPolicyData: true, - }); - const text = findExpirationPolicySubHeader(); - expect(text.exists()).toBe(false); + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(true); + expect(text.props('text')).toBe('Expiration policy will run in '); + }); + it('when the expiration policy is completely disabled', async () => { + await mountComponent({ + expirationPolicy: { enabled: true }, + expirationPolicyHelpPagePath: 'foo', + imagesCount: 1, + hideExpirationPolicyData: true, }); + + const text = findExpirationPolicySubHeader(); + expect(text.exists()).toBe(false); }); }); }); @@ -136,12 +126,13 @@ describe('registry_header', () => { describe('info area', () => { it('exists', () => { mountComponent(); + expect(findInfoArea().exists()).toBe(true); }); describe('default message', () => { beforeEach(() => { - mountComponent({ helpPagePath: 'bar' }); + return mountComponent({ helpPagePath: 'bar' }); }); it('exists', () => { @@ -165,6 +156,7 @@ describe('registry_header', () => { describe('when there are no images', () => { it('is hidden', () => { mountComponent(); + expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); }); }); @@ -172,7 +164,7 @@ describe('registry_header', () => { describe('when there are images', () => { describe('when expiration policy is disabled', () => { beforeEach(() => { - mountComponent({ + return mountComponent({ expirationPolicy: { enabled: false }, expirationPolicyHelpPagePath: 'foo', imagesCount: 1, @@ -202,6 +194,7 @@ describe('registry_header', () => { expirationPolicy: { enabled: true }, imagesCount: 1, }); + expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); }); }); @@ -212,6 +205,7 @@ describe('registry_header', () => { imagesCount: 1, hideExpirationPolicyData: true, }); + expect(findDisabledExpirationPolicyMessage().exists()).toBe(false); }); }); diff --git a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js index f04585a6ff4..b906e44a4f7 100644 --- a/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js +++ b/spec/frontend/registry/explorer/components/registry_breadcrumb_spec.js @@ -117,7 +117,7 @@ describe('Registry Breadcrumb', () => { }); it('has the same tag as the last children of the crumbs', () => { - expect(findLastCrumb().is(lastChildren.tagName)).toBe(true); + expect(findLastCrumb().element.tagName).toBe(lastChildren.tagName.toUpperCase()); }); it('has the same classes as the last children of the crumbs', () => { diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index b4e46fda2c4..b24422adb03 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -8,6 +8,7 @@ import GroupEmptyState from '~/registry/explorer/components/list_page/group_empt import ProjectEmptyState from '~/registry/explorer/components/list_page/project_empty_state.vue'; import RegistryHeader from '~/registry/explorer/components/list_page/registry_header.vue'; import ImageList from '~/registry/explorer/components/list_page/image_list.vue'; +import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { createStore } from '~/registry/explorer/stores/'; import { SET_MAIN_LOADING, @@ -54,6 +55,7 @@ describe('List Page', () => { GlEmptyState, GlSprintf, RegistryHeader, + TitleArea, }, mocks: { $toast, diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index 8f95fce2867..b6c0ee67757 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -1,5 +1,5 @@ import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; -import RealListItem from '~/registry/explorer/components/list_item.vue'; +import RealListItem from '~/vue_shared/components/registry/list_item.vue'; export const GlModal = { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', diff --git a/spec/frontend/registry/shared/mocks.js b/spec/frontend/registry/shared/mocks.js index e33d06e7499..fdef38b6f10 100644 --- a/spec/frontend/registry/shared/mocks.js +++ b/spec/frontend/registry/shared/mocks.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const $toast = { show: jest.fn(), }; diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap new file mode 100644 index 00000000000..f56e296d106 --- /dev/null +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`releases/util.js convertGraphQLResponse matches snapshot 1`] = ` +Object { + "data": Array [ + Object { + "_links": Object { + "editUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit", + "issuesUrl": null, + "mergeRequestsUrl": null, + "self": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10", + "selfUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10", + }, + "assets": Object { + "count": 7, + "links": Array [ + Object { + "directAssetUrl": "http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook", + "external": true, + "id": "gid://gitlab/Releases::Link/69", + "linkType": "other", + "name": "An example link", + "url": "https://example.com/link", + }, + Object { + "directAssetUrl": "https://example.com/package", + "external": true, + "id": "gid://gitlab/Releases::Link/68", + "linkType": "package", + "name": "An example package link", + "url": "https://example.com/package", + }, + Object { + "directAssetUrl": "https://example.com/image", + "external": true, + "id": "gid://gitlab/Releases::Link/67", + "linkType": "image", + "name": "An example image", + "url": "https://example.com/image", + }, + ], + "sources": Array [ + Object { + "format": "zip", + "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip", + }, + Object { + "format": "tar.gz", + "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz", + }, + Object { + "format": "tar.bz2", + "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2", + }, + Object { + "format": "tar", + "url": "http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar", + }, + ], + }, + "author": Object { + "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", + "username": "root", + "webUrl": "http://0.0.0.0:3000/root", + }, + "commit": Object { + "shortId": "92e7ea2e", + "title": "Testing a change.", + }, + "commitPath": "http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7", + "descriptionHtml": "<p data-sourcepos=\\"1:1-1:24\\" dir=\\"auto\\">This is version <strong>1.0</strong>!</p>", + "evidences": Array [ + Object { + "collectedAt": "2020-08-21T20:15:19Z", + "filepath": "http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json", + "sha": "22bde8e8b93d870a29ddc339287a1fbb598f45d1396d", + }, + ], + "milestones": Array [ + Object { + "description": "", + "id": "gid://gitlab/Milestone/60", + "issueStats": Object { + "closed": 0, + "total": 0, + }, + "stats": undefined, + "title": "12.4", + "webPath": undefined, + "webUrl": "/root/release-test/-/milestones/2", + }, + Object { + "description": "Milestone 12.3", + "id": "gid://gitlab/Milestone/59", + "issueStats": Object { + "closed": 1, + "total": 2, + }, + "stats": undefined, + "title": "12.3", + "webPath": undefined, + "webUrl": "/root/release-test/-/milestones/1", + }, + ], + "name": "Release 1.0", + "releasedAt": "2020-08-21T20:15:18Z", + "tagName": "v5.10", + "tagPath": "/root/release-test/-/tags/v5.10", + "upcomingRelease": false, + }, + ], +} +`; diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 8eafe07cb2f..bcb87509cc3 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,12 +1,11 @@ import { range as rge } from 'lodash'; -import Vue from 'vue'; -import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import app from '~/releases/components/app_index.vue'; +import ReleasesApp from '~/releases/components/app_index.vue'; import createStore from '~/releases/stores'; -import listModule from '~/releases/stores/modules/list'; +import createListModule from '~/releases/stores/modules/list'; import api from '~/api'; -import { resetStore } from '../stores/modules/list/helpers'; import { pageInfoHeadersWithoutPagination, pageInfoHeadersWithPagination, @@ -14,30 +13,67 @@ import { releases, } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); describe('Releases App ', () => { - const Component = Vue.extend(app); - let store; - let vm; - let releasesPagination; + let wrapper; + let fetchReleaseSpy; + + const releasesPagination = rge(21).map(index => ({ + ...convertObjectPropsToCamelCase(release, { deep: true }), + tagName: `${index}.00`, + })); - const props = { + const defaultInitialState = { projectId: 'gitlab-ce', + projectPath: 'gitlab-org/gitlab-ce', documentationPath: 'help/releases', illustrationPath: 'illustration/path', }; - beforeEach(() => { - store = createStore({ modules: { list: listModule } }); - releasesPagination = rge(21).map(index => ({ - ...convertObjectPropsToCamelCase(release, { deep: true }), - tagName: `${index}.00`, - })); - }); + const createComponent = (stateUpdates = {}) => { + const listModule = createListModule({ + ...defaultInitialState, + ...stateUpdates, + }); + + fetchReleaseSpy = jest.spyOn(listModule.actions, 'fetchReleases'); + + const store = createStore({ + modules: { list: listModule }, + featureFlags: { + graphqlReleaseData: true, + graphqlReleasesPage: false, + graphqlMilestoneStats: true, + }, + }); + + wrapper = shallowMount(ReleasesApp, { + store, + localVue, + }); + }; afterEach(() => { - resetStore(store); - vm.$destroy(); + wrapper.destroy(); + }); + + describe('on startup', () => { + beforeEach(() => { + jest + .spyOn(api, 'releases') + .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); + + createComponent(); + }); + + it('calls fetchRelease with the page parameter', () => { + expect(fetchReleaseSpy).toHaveBeenCalledTimes(1); + expect(fetchReleaseSpy).toHaveBeenCalledWith(expect.anything(), { page: null }); + }); }); describe('while loading', () => { @@ -47,16 +83,15 @@ describe('Releases App ', () => { // Need to defer the return value here to the next stack, // otherwise the loading state disappears before our test even starts. .mockImplementation(() => waitForPromises().then(() => ({ data: [], headers: {} }))); - vm = mountComponentWithStore(Component, { props, store }); + + createComponent(); }); it('renders loading icon', () => { - expect(vm.$el.querySelector('.js-loading')).not.toBeNull(); - expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); - expect(vm.$el.querySelector('.js-success-state')).toBeNull(); - expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); - - return waitForPromises(); + expect(wrapper.contains('.js-loading')).toBe(true); + expect(wrapper.contains('.js-empty-state')).toBe(false); + expect(wrapper.contains('.js-success-state')).toBe(false); + expect(wrapper.contains(TablePagination)).toBe(false); }); }); @@ -65,14 +100,15 @@ describe('Releases App ', () => { jest .spyOn(api, 'releases') .mockResolvedValue({ data: releases, headers: pageInfoHeadersWithoutPagination }); - vm = mountComponentWithStore(Component, { props, store }); + + createComponent(); }); it('renders success state', () => { - expect(vm.$el.querySelector('.js-loading')).toBeNull(); - expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); - expect(vm.$el.querySelector('.js-success-state')).not.toBeNull(); - expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); + expect(wrapper.contains('.js-loading')).toBe(false); + expect(wrapper.contains('.js-empty-state')).toBe(false); + expect(wrapper.contains('.js-success-state')).toBe(true); + expect(wrapper.contains(TablePagination)).toBe(true); }); }); @@ -81,69 +117,60 @@ describe('Releases App ', () => { jest .spyOn(api, 'releases') .mockResolvedValue({ data: releasesPagination, headers: pageInfoHeadersWithPagination }); - vm = mountComponentWithStore(Component, { props, store }); + + createComponent(); }); it('renders success state', () => { - expect(vm.$el.querySelector('.js-loading')).toBeNull(); - expect(vm.$el.querySelector('.js-empty-state')).toBeNull(); - expect(vm.$el.querySelector('.js-success-state')).not.toBeNull(); - expect(vm.$el.querySelector('.gl-pagination')).not.toBeNull(); + expect(wrapper.contains('.js-loading')).toBe(false); + expect(wrapper.contains('.js-empty-state')).toBe(false); + expect(wrapper.contains('.js-success-state')).toBe(true); + expect(wrapper.contains(TablePagination)).toBe(true); }); }); describe('with empty request', () => { beforeEach(() => { jest.spyOn(api, 'releases').mockResolvedValue({ data: [], headers: {} }); - vm = mountComponentWithStore(Component, { props, store }); + + createComponent(); }); it('renders empty state', () => { - expect(vm.$el.querySelector('.js-loading')).toBeNull(); - expect(vm.$el.querySelector('.js-empty-state')).not.toBeNull(); - expect(vm.$el.querySelector('.js-success-state')).toBeNull(); - expect(vm.$el.querySelector('.gl-pagination')).toBeNull(); + expect(wrapper.contains('.js-loading')).toBe(false); + expect(wrapper.contains('.js-empty-state')).toBe(true); + expect(wrapper.contains('.js-success-state')).toBe(false); }); }); describe('"New release" button', () => { - const findNewReleaseButton = () => vm.$el.querySelector('.js-new-release-btn'); + const findNewReleaseButton = () => wrapper.find('.js-new-release-btn'); beforeEach(() => { jest.spyOn(api, 'releases').mockResolvedValue({ data: [], headers: {} }); }); - const factory = additionalProps => { - vm = mountComponentWithStore(Component, { - props: { - ...props, - ...additionalProps, - }, - store, - }); - }; - describe('when the user is allowed to create a new Release', () => { const newReleasePath = 'path/to/new/release'; beforeEach(() => { - factory({ newReleasePath }); + createComponent({ ...defaultInitialState, newReleasePath }); }); it('renders the "New release" button', () => { - expect(findNewReleaseButton()).not.toBeNull(); + expect(findNewReleaseButton().exists()).toBe(true); }); it('renders the "New release" button with the correct href', () => { - expect(findNewReleaseButton().getAttribute('href')).toBe(newReleasePath); + expect(findNewReleaseButton().attributes('href')).toBe(newReleasePath); }); }); describe('when the user is not allowed to create a new Release', () => { - beforeEach(() => factory()); + beforeEach(() => createComponent()); it('does not render the "New release" button', () => { - expect(findNewReleaseButton()).toBeNull(); + expect(findNewReleaseButton().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index e757fe98661..502a1053663 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading } from '@gitlab/ui'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import { release as originalRelease } from '../mock_data'; import ReleaseBlock from '~/releases/components/release_block.vue'; diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index 727d593d851..582c0b32716 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -115,14 +115,10 @@ describe('Release edit component', () => { const expectStoreMethodToBeCalled = () => { expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newUrl, - }, - undefined, - ); + expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith(expect.anything(), { + linkIdToUpdate, + newUrl, + }); }; it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => { @@ -177,14 +173,10 @@ describe('Release edit component', () => { const expectStoreMethodToBeCalled = () => { expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkName).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newName, - }, - undefined, - ); + expect(actions.updateAssetLinkName).toHaveBeenCalledWith(expect.anything(), { + linkIdToUpdate, + newName, + }); }; it('calls the "updateAssetLinkName" store method when text is entered into the "Link title" input field', () => { @@ -225,14 +217,10 @@ describe('Release edit component', () => { wrapper.find({ ref: 'typeSelect' }).vm.$emit('change', newType); expect(actions.updateAssetLinkType).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkType).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newType, - }, - undefined, - ); + expect(actions.updateAssetLinkType).toHaveBeenCalledWith(expect.anything(), { + linkIdToUpdate, + newType, + }); }); it('selects the default asset type if no type was provided by the backend', () => { diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index 5e84290716c..3453ecbf8ab 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -128,7 +128,7 @@ describe('Release block assets', () => { describe('external vs internal links', () => { const containsExternalSourceIndicator = () => - wrapper.contains('[data-testid="external-link-indicator"]'); + wrapper.find('[data-testid="external-link-indicator"]').exists(); describe('when a link is external', () => { beforeEach(() => { diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index c066bfbf020..bde01cc0e00 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -1,9 +1,8 @@ import { mount } from '@vue/test-utils'; -import { GlLink } from '@gitlab/ui'; +import { GlLink, GlIcon } from '@gitlab/ui'; import { trimText } from 'helpers/text_helper'; import { cloneDeep } from 'lodash'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; @@ -56,7 +55,7 @@ describe('Release block footer', () => { beforeEach(() => factory()); it('renders the commit icon', () => { - const commitIcon = commitInfoSection().find(Icon); + const commitIcon = commitInfoSection().find(GlIcon); expect(commitIcon.exists()).toBe(true); expect(commitIcon.props('name')).toBe('commit'); @@ -71,7 +70,7 @@ describe('Release block footer', () => { }); it('renders the tag icon', () => { - const commitIcon = tagInfoSection().find(Icon); + const commitIcon = tagInfoSection().find(GlIcon); expect(commitIcon.exists()).toBe(true); expect(commitIcon.props('name')).toBe('tag'); diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index 19119d99f3c..a7f1388664b 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import { mount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import EvidenceBlock from '~/releases/components/evidence_block.vue'; import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import timeagoMixin from '~/vue_shared/mixins/timeago'; import { release as originalRelease } from '../mock_data'; -import Icon from '~/vue_shared/components/icon.vue'; import * as commonUtils from '~/lib/utils/common_utils'; import { BACK_URL_PARAM } from '~/releases/constants'; import * as urlUtility from '~/lib/utils/url_utility'; @@ -247,7 +247,7 @@ describe('Release block', () => { it('renders the milestone icon', () => { expect( milestoneListLabel() - .find(Icon) + .find(GlIcon) .exists(), ).toBe(true); }); diff --git a/spec/frontend/releases/components/releases_pagination_graphql_spec.js b/spec/frontend/releases/components/releases_pagination_graphql_spec.js new file mode 100644 index 00000000000..b01a28eb6c3 --- /dev/null +++ b/spec/frontend/releases/components/releases_pagination_graphql_spec.js @@ -0,0 +1,175 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import createStore from '~/releases/stores'; +import createListModule from '~/releases/stores/modules/list'; +import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue'; +import { historyPushState } from '~/lib/utils/common_utils'; + +jest.mock('~/lib/utils/common_utils', () => ({ + ...jest.requireActual('~/lib/utils/common_utils'), + historyPushState: jest.fn(), +})); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('~/releases/components/releases_pagination_graphql.vue', () => { + let wrapper; + let listModule; + + const cursors = { + startCursor: 'startCursor', + endCursor: 'endCursor', + }; + + const projectPath = 'my/project'; + + const createComponent = pageInfo => { + listModule = createListModule({ projectPath }); + + listModule.state.graphQlPageInfo = pageInfo; + + listModule.actions.fetchReleasesGraphQl = jest.fn(); + + wrapper = mount(ReleasesPaginationGraphql, { + store: createStore({ + modules: { + list: listModule, + }, + featureFlags: {}, + }), + localVue, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findPrevButton = () => wrapper.find('[data-testid="prevButton"]'); + const findNextButton = () => wrapper.find('[data-testid="nextButton"]'); + + const expectDisabledPrev = () => { + expect(findPrevButton().attributes().disabled).toBe('disabled'); + }; + const expectEnabledPrev = () => { + expect(findPrevButton().attributes().disabled).toBe(undefined); + }; + const expectDisabledNext = () => { + expect(findNextButton().attributes().disabled).toBe('disabled'); + }; + const expectEnabledNext = () => { + expect(findNextButton().attributes().disabled).toBe(undefined); + }; + + describe('when there is only one page of results', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: false, + hasNextPage: false, + }); + }); + + it('does not render anything', () => { + expect(wrapper.isEmpty()).toBe(true); + }); + }); + + describe('when there is a next page, but not a previous page', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: false, + hasNextPage: true, + }); + }); + + it('renders a disabled "Prev" button', () => { + expectDisabledPrev(); + }); + + it('renders an enabled "Next" button', () => { + expectEnabledNext(); + }); + }); + + describe('when there is a previous page, but not a next page', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: true, + hasNextPage: false, + }); + }); + + it('renders a enabled "Prev" button', () => { + expectEnabledPrev(); + }); + + it('renders an disabled "Next" button', () => { + expectDisabledNext(); + }); + }); + + describe('when there is both a previous page and a next page', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: true, + hasNextPage: true, + }); + }); + + it('renders a enabled "Prev" button', () => { + expectEnabledPrev(); + }); + + it('renders an enabled "Next" button', () => { + expectEnabledNext(); + }); + }); + + describe('button behavior', () => { + beforeEach(() => { + createComponent({ + hasPreviousPage: true, + hasNextPage: true, + ...cursors, + }); + }); + + describe('next button behavior', () => { + beforeEach(() => { + findNextButton().trigger('click'); + }); + + it('calls fetchReleasesGraphQl with the correct after cursor', () => { + expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([ + [expect.anything(), { after: cursors.endCursor }], + ]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?after=${cursors.endCursor}`)], + ]); + }); + }); + + describe('previous button behavior', () => { + beforeEach(() => { + findPrevButton().trigger('click'); + }); + + it('calls fetchReleasesGraphQl with the correct before cursor', () => { + expect(listModule.actions.fetchReleasesGraphQl.mock.calls).toEqual([ + [expect.anything(), { before: cursors.startCursor }], + ]); + }); + + it('calls historyPushState with the new URL', () => { + expect(historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?before=${cursors.startCursor}`)], + ]); + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/releases_pagination_rest_spec.js b/spec/frontend/releases/components/releases_pagination_rest_spec.js new file mode 100644 index 00000000000..4fd3e085fc9 --- /dev/null +++ b/spec/frontend/releases/components/releases_pagination_rest_spec.js @@ -0,0 +1,72 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { GlPagination } from '@gitlab/ui'; +import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue'; +import createStore from '~/releases/stores'; +import createListModule from '~/releases/stores/modules/list'; +import * as commonUtils from '~/lib/utils/common_utils'; + +commonUtils.historyPushState = jest.fn(); + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('~/releases/components/releases_pagination_rest.vue', () => { + let wrapper; + let listModule; + + const projectId = 19; + + const createComponent = pageInfo => { + listModule = createListModule({ projectId }); + + listModule.state.pageInfo = pageInfo; + + listModule.actions.fetchReleasesRest = jest.fn(); + + wrapper = mount(ReleasesPaginationRest, { + store: createStore({ + modules: { + list: listModule, + }, + featureFlags: {}, + }), + localVue, + }); + }; + + const findGlPagination = () => wrapper.find(GlPagination); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when a page number is clicked', () => { + const newPage = 2; + + beforeEach(() => { + createComponent({ + perPage: 20, + page: 1, + total: 40, + totalPages: 2, + nextPage: 2, + }); + + findGlPagination().vm.$emit('input', newPage); + }); + + it('calls fetchReleasesRest with the correct page', () => { + expect(listModule.actions.fetchReleasesRest.mock.calls).toEqual([ + [expect.anything(), { page: newPage }], + ]); + }); + + it('calls historyPushState with the new URL', () => { + expect(commonUtils.historyPushState.mock.calls).toEqual([ + [expect.stringContaining(`?page=${newPage}`)], + ]); + }); + }); +}); diff --git a/spec/frontend/releases/components/releases_pagination_spec.js b/spec/frontend/releases/components/releases_pagination_spec.js new file mode 100644 index 00000000000..2466fb53a68 --- /dev/null +++ b/spec/frontend/releases/components/releases_pagination_spec.js @@ -0,0 +1,52 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import ReleasesPagination from '~/releases/components/releases_pagination.vue'; +import ReleasesPaginationGraphql from '~/releases/components/releases_pagination_graphql.vue'; +import ReleasesPaginationRest from '~/releases/components/releases_pagination_rest.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('~/releases/components/releases_pagination.vue', () => { + let wrapper; + + const createComponent = useGraphQLEndpoint => { + const store = new Vuex.Store({ + getters: { + useGraphQLEndpoint: () => useGraphQLEndpoint, + }, + }); + + wrapper = shallowMount(ReleasesPagination, { store, localVue }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findRestPagination = () => wrapper.find(ReleasesPaginationRest); + const findGraphQlPagination = () => wrapper.find(ReleasesPaginationGraphql); + + describe('when one of necessary feature flags is disabled', () => { + beforeEach(() => { + createComponent(false); + }); + + it('renders the REST pagination component', () => { + expect(findRestPagination().exists()).toBe(true); + expect(findGraphQlPagination().exists()).toBe(false); + }); + }); + + describe('when all the necessary feature flags are enabled', () => { + beforeEach(() => { + createComponent(true); + }); + + it('renders the GraphQL pagination component', () => { + expect(findGraphQlPagination().exists()).toBe(true); + expect(findRestPagination().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js index 0a04f68bd67..70a195556df 100644 --- a/spec/frontend/releases/components/tag_field_exsting_spec.js +++ b/spec/frontend/releases/components/tag_field_exsting_spec.js @@ -1,5 +1,6 @@ +import Vuex from 'vuex'; import { GlFormInput } from '@gitlab/ui'; -import { shallowMount, mount } from '@vue/test-utils'; +import { shallowMount, mount, createLocalVue } from '@vue/test-utils'; import TagFieldExisting from '~/releases/components/tag_field_existing.vue'; import createStore from '~/releases/stores'; import createDetailModule from '~/releases/stores/modules/detail'; @@ -7,6 +8,9 @@ import createDetailModule from '~/releases/stores/modules/detail'; const TEST_TAG_NAME = 'test-tag-name'; const TEST_DOCS_PATH = '/help/test/docs/path'; +const localVue = createLocalVue(); +localVue.use(Vuex); + describe('releases/components/tag_field_existing', () => { let store; let wrapper; @@ -14,6 +18,7 @@ describe('releases/components/tag_field_existing', () => { const createComponent = (mountFn = shallowMount) => { wrapper = mountFn(TagFieldExisting, { store, + localVue, }); }; diff --git a/spec/frontend/releases/mock_data.js b/spec/frontend/releases/mock_data.js index b97385154bd..58cd69a2f6a 100644 --- a/spec/frontend/releases/mock_data.js +++ b/spec/frontend/releases/mock_data.js @@ -222,3 +222,131 @@ export const release2 = { }; export const releases = [release, release2]; + +export const graphqlReleasesResponse = { + data: { + project: { + releases: { + count: 39, + nodes: [ + { + name: 'Release 1.0', + tagName: 'v5.10', + tagPath: '/root/release-test/-/tags/v5.10', + descriptionHtml: + '<p data-sourcepos="1:1-1:24" dir="auto">This is version <strong>1.0</strong>!</p>', + releasedAt: '2020-08-21T20:15:18Z', + upcomingRelease: false, + assets: { + count: 7, + sources: { + nodes: [ + { + format: 'zip', + url: + 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.zip', + }, + { + format: 'tar.gz', + url: + 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.gz', + }, + { + format: 'tar.bz2', + url: + 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar.bz2', + }, + { + format: 'tar', + url: + 'http://0.0.0.0:3000/root/release-test/-/archive/v5.10/release-test-v5.10.tar', + }, + ], + }, + links: { + nodes: [ + { + id: 'gid://gitlab/Releases::Link/69', + name: 'An example link', + url: 'https://example.com/link', + directAssetUrl: + 'http://0.0.0.0:3000/root/release-test/-/releases/v5.32/permanent/path/to/runbook', + linkType: 'OTHER', + external: true, + }, + { + id: 'gid://gitlab/Releases::Link/68', + name: 'An example package link', + url: 'https://example.com/package', + directAssetUrl: 'https://example.com/package', + linkType: 'PACKAGE', + external: true, + }, + { + id: 'gid://gitlab/Releases::Link/67', + name: 'An example image', + url: 'https://example.com/image', + directAssetUrl: 'https://example.com/image', + linkType: 'IMAGE', + external: true, + }, + ], + }, + }, + evidences: { + nodes: [ + { + filepath: + 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/evidences/34.json', + collectedAt: '2020-08-21T20:15:19Z', + sha: '22bde8e8b93d870a29ddc339287a1fbb598f45d1396d', + }, + ], + }, + links: { + editUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10/edit', + issuesUrl: null, + mergeRequestsUrl: null, + selfUrl: 'http://0.0.0.0:3000/root/release-test/-/releases/v5.10', + }, + commit: { + sha: '92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7', + webUrl: + 'http://0.0.0.0:3000/root/release-test/-/commit/92e7ea2ee4496fe0d00ff69830ba0564d3d1e5a7', + title: 'Testing a change.', + }, + author: { + webUrl: 'http://0.0.0.0:3000/root', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png', + username: 'root', + }, + milestones: { + nodes: [ + { + id: 'gid://gitlab/Milestone/60', + title: '12.4', + description: '', + webPath: '/root/release-test/-/milestones/2', + stats: { + totalIssuesCount: 0, + closedIssuesCount: 0, + }, + }, + { + id: 'gid://gitlab/Milestone/59', + title: '12.3', + description: 'Milestone 12.3', + webPath: '/root/release-test/-/milestones/1', + stats: { + totalIssuesCount: 2, + closedIssuesCount: 1, + }, + }, + ], + }, + }, + ], + }, + }, + }, +}; diff --git a/spec/frontend/releases/stores/modules/list/actions_spec.js b/spec/frontend/releases/stores/modules/list/actions_spec.js index 4c3af157684..95e30659d6c 100644 --- a/spec/frontend/releases/stores/modules/list/actions_spec.js +++ b/spec/frontend/releases/stores/modules/list/actions_spec.js @@ -1,3 +1,4 @@ +import { cloneDeep } from 'lodash'; import testAction from 'helpers/vuex_action_helper'; import { requestReleases, @@ -5,21 +6,43 @@ import { receiveReleasesSuccess, receiveReleasesError, } from '~/releases/stores/modules/list/actions'; -import state from '~/releases/stores/modules/list/state'; +import createState from '~/releases/stores/modules/list/state'; import * as types from '~/releases/stores/modules/list/mutation_types'; import api from '~/api'; +import { gqClient, convertGraphQLResponse } from '~/releases/util'; import { parseIntPagination, convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { pageInfoHeadersWithoutPagination, releases as originalReleases } from '../../../mock_data'; +import { + pageInfoHeadersWithoutPagination, + releases as originalReleases, + graphqlReleasesResponse as originalGraphqlReleasesResponse, +} from '../../../mock_data'; +import allReleasesQuery from '~/releases/queries/all_releases.query.graphql'; describe('Releases State actions', () => { let mockedState; let pageInfo; let releases; + let graphqlReleasesResponse; + + const projectPath = 'root/test-project'; + const projectId = 19; beforeEach(() => { - mockedState = state(); + mockedState = { + ...createState({ + projectId, + projectPath, + }), + featureFlags: { + graphqlReleaseData: true, + graphqlReleasesPage: true, + graphqlMilestoneStats: true, + }, + }; + pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); releases = convertObjectPropsToCamelCase(originalReleases, { deep: true }); + graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); }); describe('requestReleases', () => { @@ -31,15 +54,17 @@ describe('Releases State actions', () => { describe('fetchReleases', () => { describe('success', () => { it('dispatches requestReleases and receiveReleasesSuccess', done => { - jest.spyOn(api, 'releases').mockImplementation((id, options) => { - expect(id).toEqual(1); - expect(options.page).toEqual('1'); - return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + jest.spyOn(gqClient, 'query').mockImplementation(({ query, variables }) => { + expect(query).toBe(allReleasesQuery); + expect(variables).toEqual({ + fullPath: projectPath, + }); + return Promise.resolve(graphqlReleasesResponse); }); testAction( fetchReleases, - { projectId: 1 }, + {}, mockedState, [], [ @@ -47,31 +72,7 @@ describe('Releases State actions', () => { type: 'requestReleases', }, { - payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, - type: 'receiveReleasesSuccess', - }, - ], - done, - ); - }); - - it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { - jest.spyOn(api, 'releases').mockImplementation((_, options) => { - expect(options.page).toEqual('2'); - return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); - }); - - testAction( - fetchReleases, - { page: '2', projectId: 1 }, - mockedState, - [], - [ - { - type: 'requestReleases', - }, - { - payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, + payload: convertGraphQLResponse(graphqlReleasesResponse), type: 'receiveReleasesSuccess', }, ], @@ -82,11 +83,11 @@ describe('Releases State actions', () => { describe('error', () => { it('dispatches requestReleases and receiveReleasesError', done => { - jest.spyOn(api, 'releases').mockReturnValue(Promise.reject()); + jest.spyOn(gqClient, 'query').mockRejectedValue(); testAction( fetchReleases, - { projectId: null }, + {}, mockedState, [], [ @@ -101,6 +102,85 @@ describe('Releases State actions', () => { ); }); }); + + describe('when the graphqlReleaseData feature flag is disabled', () => { + beforeEach(() => { + mockedState.featureFlags.graphqlReleasesPage = false; + }); + + describe('success', () => { + it('dispatches requestReleases and receiveReleasesSuccess', done => { + jest.spyOn(api, 'releases').mockImplementation((id, options) => { + expect(id).toBe(projectId); + expect(options.page).toBe('1'); + return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); + + testAction( + fetchReleases, + {}, + mockedState, + [], + [ + { + type: 'requestReleases', + }, + { + payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, + type: 'receiveReleasesSuccess', + }, + ], + done, + ); + }); + + it('dispatches requestReleases and receiveReleasesSuccess on page two', done => { + jest.spyOn(api, 'releases').mockImplementation((_, options) => { + expect(options.page).toBe('2'); + return Promise.resolve({ data: releases, headers: pageInfoHeadersWithoutPagination }); + }); + + testAction( + fetchReleases, + { page: '2' }, + mockedState, + [], + [ + { + type: 'requestReleases', + }, + { + payload: { data: releases, headers: pageInfoHeadersWithoutPagination }, + type: 'receiveReleasesSuccess', + }, + ], + done, + ); + }); + }); + + describe('error', () => { + it('dispatches requestReleases and receiveReleasesError', done => { + jest.spyOn(api, 'releases').mockReturnValue(Promise.reject()); + + testAction( + fetchReleases, + {}, + mockedState, + [], + [ + { + type: 'requestReleases', + }, + { + type: 'receiveReleasesError', + }, + ], + done, + ); + }); + }); + }); }); describe('receiveReleasesSuccess', () => { diff --git a/spec/frontend/releases/stores/modules/list/helpers.js b/spec/frontend/releases/stores/modules/list/helpers.js index 435ca36047e..3ca255eaf8c 100644 --- a/spec/frontend/releases/stores/modules/list/helpers.js +++ b/spec/frontend/releases/stores/modules/list/helpers.js @@ -1,6 +1,5 @@ import state from '~/releases/stores/modules/list/state'; -// eslint-disable-next-line import/prefer-default-export export const resetStore = store => { store.replaceState(state()); }; diff --git a/spec/frontend/releases/stores/modules/list/mutations_spec.js b/spec/frontend/releases/stores/modules/list/mutations_spec.js index 3035b916ff6..27ad05846e7 100644 --- a/spec/frontend/releases/stores/modules/list/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/list/mutations_spec.js @@ -1,4 +1,4 @@ -import state from '~/releases/stores/modules/list/state'; +import createState from '~/releases/stores/modules/list/state'; import mutations from '~/releases/stores/modules/list/mutations'; import * as types from '~/releases/stores/modules/list/mutation_types'; import { parseIntPagination } from '~/lib/utils/common_utils'; @@ -9,7 +9,7 @@ describe('Releases Store Mutations', () => { let pageInfo; beforeEach(() => { - stateCopy = state(); + stateCopy = createState({}); pageInfo = parseIntPagination(pageInfoHeadersWithoutPagination); }); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js index 90aa9c4c7d8..f40e5729188 100644 --- a/spec/frontend/releases/util_spec.js +++ b/spec/frontend/releases/util_spec.js @@ -1,4 +1,6 @@ -import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; +import { cloneDeep } from 'lodash'; +import { releaseToApiJson, apiJsonToRelease, convertGraphQLResponse } from '~/releases/util'; +import { graphqlReleasesResponse as originalGraphqlReleasesResponse } from './mock_data'; describe('releases/util.js', () => { describe('releaseToApiJson', () => { @@ -100,4 +102,55 @@ describe('releases/util.js', () => { expect(apiJsonToRelease(json)).toEqual(expectedRelease); }); }); + + describe('convertGraphQLResponse', () => { + let graphqlReleasesResponse; + let converted; + + beforeEach(() => { + graphqlReleasesResponse = cloneDeep(originalGraphqlReleasesResponse); + converted = convertGraphQLResponse(graphqlReleasesResponse); + }); + + it('matches snapshot', () => { + expect(converted).toMatchSnapshot(); + }); + + describe('assets', () => { + it("handles asset links that don't have a linkType", () => { + expect(converted.data[0].assets.links[0].linkType).not.toBeUndefined(); + + delete graphqlReleasesResponse.data.project.releases.nodes[0].assets.links.nodes[0] + .linkType; + + converted = convertGraphQLResponse(graphqlReleasesResponse); + + expect(converted.data[0].assets.links[0].linkType).toBeUndefined(); + }); + }); + + describe('_links', () => { + it("handles releases that don't have any links", () => { + expect(converted.data[0]._links.selfUrl).not.toBeUndefined(); + + delete graphqlReleasesResponse.data.project.releases.nodes[0].links; + + converted = convertGraphQLResponse(graphqlReleasesResponse); + + expect(converted.data[0]._links.selfUrl).toBeUndefined(); + }); + }); + + describe('commit', () => { + it("handles releases that don't have any commit info", () => { + expect(converted.data[0].commit).not.toBeUndefined(); + + delete graphqlReleasesResponse.data.project.releases.nodes[0].commit; + + converted = convertGraphQLResponse(graphqlReleasesResponse); + + expect(converted.data[0].commit).toBeUndefined(); + }); + }); + }); }); diff --git a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js index a036588596a..ccceb78f2d1 100644 --- a/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js +++ b/spec/frontend/reports/accessibility_report/grouped_accessibility_reports_app_spec.js @@ -2,7 +2,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import GroupedAccessibilityReportsApp from '~/reports/accessibility_report/grouped_accessibility_reports_app.vue'; import AccessibilityIssueBody from '~/reports/accessibility_report/components/accessibility_issue_body.vue'; -import store from '~/reports/accessibility_report/store'; +import { getStoreConfig } from '~/reports/accessibility_report/store'; import { mockReport } from './mock_data'; const localVue = createLocalVue(); @@ -20,16 +20,17 @@ describe('Grouped accessibility reports app', () => { propsData: { endpoint: 'endpoint.json', }, - methods: { - fetchReport: () => {}, - }, }); }; const findHeader = () => wrapper.find('[data-testid="report-section-code-text"]'); beforeEach(() => { - mockStore = store(); + mockStore = new Vuex.Store({ + ...getStoreConfig(), + actions: { fetchReport: () => {}, setEndpoint: () => {} }, + }); + mountComponent(); }); diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js index 20ad01bd802..9dace1e7c54 100644 --- a/spec/frontend/reports/accessibility_report/mock_data.js +++ b/spec/frontend/reports/accessibility_report/mock_data.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const mockReport = { status: 'failed', summary: { diff --git a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js index 1905ca0d5e1..77d7c6f8678 100644 --- a/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js +++ b/spec/frontend/reports/codequality_report/grouped_codequality_reports_app_spec.js @@ -2,7 +2,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import GroupedCodequalityReportsApp from '~/reports/codequality_report/grouped_codequality_reports_app.vue'; import CodequalityIssueBody from '~/reports/codequality_report/components/codequality_issue_body.vue'; -import store from '~/reports/codequality_report/store'; +import { getStoreConfig } from '~/reports/codequality_report/store'; import { mockParsedHeadIssues, mockParsedBaseIssues } from './mock_data'; const localVue = createLocalVue(); @@ -13,21 +13,22 @@ describe('Grouped code quality reports app', () => { let wrapper; let mockStore; + const PATHS = { + codequalityHelpPath: 'codequality_help.html', + basePath: 'base.json', + headPath: 'head.json', + baseBlobPath: 'base/blob/path/', + headBlobPath: 'head/blob/path/', + }; + const mountComponent = (props = {}) => { wrapper = mount(Component, { store: mockStore, localVue, propsData: { - basePath: 'base.json', - headPath: 'head.json', - baseBlobPath: 'base/blob/path/', - headBlobPath: 'head/blob/path/', - codequalityHelpPath: 'codequality_help.html', + ...PATHS, ...props, }, - methods: { - fetchReports: () => {}, - }, }); }; @@ -35,7 +36,19 @@ describe('Grouped code quality reports app', () => { const findIssueBody = () => wrapper.find(CodequalityIssueBody); beforeEach(() => { - mockStore = store(); + const { state, ...storeConfig } = getStoreConfig(); + mockStore = new Vuex.Store({ + ...storeConfig, + actions: { + setPaths: () => {}, + fetchReports: () => {}, + }, + state: { + ...state, + ...PATHS, + }, + }); + mountComponent(); }); @@ -126,7 +139,11 @@ describe('Grouped code quality reports app', () => { }); it('renders a help icon with more information', () => { - expect(findWidget().html()).toContain('ic-question'); + expect( + findWidget() + .find('[data-testid="question-icon"]') + .exists(), + ).toBe(true); }); }); @@ -140,7 +157,11 @@ describe('Grouped code quality reports app', () => { }); it('does not render a help icon', () => { - expect(findWidget().html()).not.toContain('ic-question'); + expect( + findWidget() + .find('[data-testid="question-icon"]') + .exists(), + ).toBe(false); }); }); }); diff --git a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap index 70e1ff01323..b5a4cb42463 100644 --- a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap +++ b/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap @@ -4,7 +4,7 @@ exports[`IssueStatusIcon renders "failed" state correctly 1`] = ` <div class="report-block-list-icon failed" > - <icon-stub + <gl-icon-stub data-qa-selector="status_failed_icon" name="status_failed_borderless" size="24" @@ -16,7 +16,7 @@ exports[`IssueStatusIcon renders "neutral" state correctly 1`] = ` <div class="report-block-list-icon neutral" > - <icon-stub + <gl-icon-stub data-qa-selector="status_neutral_icon" name="dash" size="24" @@ -28,7 +28,7 @@ exports[`IssueStatusIcon renders "success" state correctly 1`] = ` <div class="report-block-list-icon success" > - <icon-stub + <gl-icon-stub data-qa-selector="status_success_icon" name="status_success_borderless" size="24" diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/reports/components/grouped_issues_list_spec.js index 1f8f4a0e4c1..1172e514707 100644 --- a/spec/frontend/reports/components/grouped_issues_list_spec.js +++ b/spec/frontend/reports/components/grouped_issues_list_spec.js @@ -42,7 +42,7 @@ describe('Grouped Issues List', () => { }); it.each('resolved', 'unresolved')('does not render report items for %s issues', () => { - expect(wrapper.contains(ReportItem)).toBe(false); + expect(wrapper.find(ReportItem).exists()).toBe(false); }); }); diff --git a/spec/frontend/reports/components/grouped_test_reports_app_spec.js b/spec/frontend/reports/components/grouped_test_reports_app_spec.js index c26e2fbc19a..556904b7da5 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -1,7 +1,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import GroupedTestReportsApp from '~/reports/components/grouped_test_reports_app.vue'; -import store from '~/reports/store'; +import { getStoreConfig } from '~/reports/store'; import { failedReport } from '../mock_data/mock_data'; import successTestReports from '../mock_data/no_failures_report.json'; @@ -29,9 +29,6 @@ describe('Grouped test reports app', () => { pipelinePath, ...props, }, - methods: { - fetchReports: () => {}, - }, }); }; @@ -49,7 +46,13 @@ describe('Grouped test reports app', () => { wrapper.findAll('[data-testid="test-issue-body-description"]'); beforeEach(() => { - mockStore = store(); + mockStore = new Vuex.Store({ + ...getStoreConfig(), + actions: { + fetchReports: () => {}, + setEndpoint: () => {}, + }, + }); mountComponent(); }); diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 10669330b61..1b8bbd5af6b 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlSkeletonLoading } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlButton } from '@gitlab/ui'; import Table from '~/repository/components/table/index.vue'; import TableRow from '~/repository/components/table/row.vue'; @@ -34,12 +34,13 @@ const MOCK_BLOBS = [ }, ]; -function factory({ path, isLoading = false, entries = {} }) { +function factory({ path, isLoading = false, hasMore = true, entries = {} }) { vm = shallowMount(Table, { propsData: { path, isLoading, entries, + hasMore, }, mocks: { $apollo, @@ -88,4 +89,27 @@ describe('Repository table component', () => { expect(rows.length).toEqual(3); expect(rows.at(2).attributes().mode).toEqual('120000'); }); + + describe('Show more button', () => { + const showMoreButton = () => vm.find(GlButton); + + it.each` + hasMore | expectButtonToExist + ${true} | ${true} + ${false} | ${false} + `('renders correctly', ({ hasMore, expectButtonToExist }) => { + factory({ path: '/', hasMore }); + expect(showMoreButton().exists()).toBe(expectButtonToExist); + }); + + it('when is clicked, emits showMore event', async () => { + factory({ path: '/' }); + + showMoreButton().vm.$emit('click'); + + await vm.vm.$nextTick(); + + expect(vm.emitted('showMore')).toHaveLength(1); + }); + }); }); diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index ea85cd34743..70dbfaea551 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton } from '@gitlab/ui'; import TreeContent, { INITIAL_FETCH_COUNT } from '~/repository/components/tree_content.vue'; import FilePreview from '~/repository/components/preview/index.vue'; +import FileTable from '~/repository/components/table/index.vue'; let vm; let $apollo; @@ -82,41 +82,36 @@ describe('Repository table component', () => { }); }); - describe('Show more button', () => { - const showMoreButton = () => vm.find(GlButton); - + describe('FileTable showMore', () => { describe('when is present', () => { + const fileTable = () => vm.find(FileTable); + beforeEach(async () => { factory('/'); - - vm.setData({ fetchCounter: 10, clickedShowMore: false }); - - await vm.vm.$nextTick(); }); - it('is not rendered once it is clicked', async () => { - showMoreButton().vm.$emit('click'); + it('is changes hasShowMore to false when "showMore" event is emitted', async () => { + fileTable().vm.$emit('showMore'); + await vm.vm.$nextTick(); - expect(showMoreButton().exists()).toBe(false); + expect(vm.vm.hasShowMore).toBe(false); }); - it('is rendered', async () => { - expect(showMoreButton().exists()).toBe(true); - }); + it('changes clickedShowMore when "showMore" event is emitted', async () => { + fileTable().vm.$emit('showMore'); - it('changes clickedShowMore when show more button is clicked', async () => { - showMoreButton().vm.$emit('click'); + await vm.vm.$nextTick(); expect(vm.vm.clickedShowMore).toBe(true); }); - it('triggers fetchFiles when show more button is clicked', async () => { + it('triggers fetchFiles when "showMore" event is emitted', () => { jest.spyOn(vm.vm, 'fetchFiles'); - showMoreButton().vm.$emit('click'); + fileTable().vm.$emit('showMore'); - expect(vm.vm.fetchFiles).toBeCalled(); + expect(vm.vm.fetchFiles).toHaveBeenCalled(); }); }); @@ -127,7 +122,7 @@ describe('Repository table component', () => { await vm.vm.$nextTick(); - expect(showMoreButton().exists()).toBe(false); + expect(vm.vm.hasShowMore).toBe(false); }); it('has limit of 1000 files on initial load', () => { diff --git a/spec/frontend/repository/components/web_ide_link_spec.js b/spec/frontend/repository/components/web_ide_link_spec.js deleted file mode 100644 index 877756db364..00000000000 --- a/spec/frontend/repository/components/web_ide_link_spec.js +++ /dev/null @@ -1,51 +0,0 @@ -import { mount } from '@vue/test-utils'; -import WebIdeLink from '~/repository/components/web_ide_link.vue'; - -describe('Web IDE link component', () => { - let wrapper; - - function createComponent(props) { - wrapper = mount(WebIdeLink, { - propsData: { ...props }, - mocks: { - $route: { - params: {}, - }, - }, - }); - } - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders link to the Web IDE for a project if only projectPath is given', () => { - createComponent({ projectPath: 'gitlab-org/gitlab', refSha: 'master' }); - - expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/'); - expect(wrapper.text()).toBe('Web IDE'); - }); - - it('renders link to the Web IDE for a project even if both projectPath and forkPath are given', () => { - createComponent({ - projectPath: 'gitlab-org/gitlab', - refSha: 'master', - forkPath: 'my-namespace/gitlab', - }); - - expect(wrapper.attributes('href')).toBe('/-/ide/project/gitlab-org/gitlab/edit/master/-/'); - expect(wrapper.text()).toBe('Web IDE'); - }); - - it('renders link to the forked project if it exists and cannot write to the repo', () => { - createComponent({ - projectPath: 'gitlab-org/gitlab', - refSha: 'master', - forkPath: 'my-namespace/gitlab', - canPushCode: false, - }); - - expect(wrapper.attributes('href')).toBe('/-/ide/project/my-namespace/gitlab/edit/master/-/'); - expect(wrapper.text()).toBe('Edit fork in Web IDE'); - }); -}); diff --git a/spec/frontend/repository/log_tree_spec.js b/spec/frontend/repository/log_tree_spec.js index 5637d0be957..954424b5c8a 100644 --- a/spec/frontend/repository/log_tree_spec.js +++ b/spec/frontend/repository/log_tree_spec.js @@ -100,24 +100,27 @@ describe('fetchLogsTree', () => { ); })); - it('writes query to client', () => - fetchLogsTree(client, '', '0', resolver).then(() => { - expect(client.writeQuery).toHaveBeenCalledWith({ - query: expect.anything(), - data: { - commits: [ - expect.objectContaining({ - __typename: 'LogTreeCommit', - commitPath: 'https://test.com', - committedDate: '2019-01-01', - fileName: 'index.js', - filePath: '/index.js', - message: 'testing message', - sha: '123', - type: 'blob', - }), - ], - }, - }); - })); + it('writes query to client', async () => { + await fetchLogsTree(client, '', '0', resolver); + expect(client.writeQuery).toHaveBeenCalledWith({ + query: expect.anything(), + data: { + projectPath: 'gitlab-org/gitlab-foss', + escapedRef: 'master', + commits: [ + expect.objectContaining({ + __typename: 'LogTreeCommit', + commitPath: 'https://test.com', + committedDate: '2019-01-01', + fileName: 'index.js', + filePath: '/index.js', + message: 'testing message', + sha: '123', + titleHtml: undefined, + type: 'blob', + }), + ], + }, + }); + }); }); diff --git a/spec/frontend/repository/router_spec.js b/spec/frontend/repository/router_spec.js index f2f3dda41d9..3c7dda05ca3 100644 --- a/spec/frontend/repository/router_spec.js +++ b/spec/frontend/repository/router_spec.js @@ -4,12 +4,13 @@ import createRouter from '~/repository/router'; describe('Repository router spec', () => { it.each` - path | branch | component | componentName - ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} - ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} - ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} + path | branch | component | componentName + ${'/'} | ${'master'} | ${IndexPage} | ${'IndexPage'} + ${'/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/tree/feat(test)'} | ${'feat(test)'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/master'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/master/app/assets'} | ${'master'} | ${TreePage} | ${'TreePage'} + ${'/-/tree/123/app/assets'} | ${'master'} | ${null} | ${'null'} `('sets component as $componentName for path "$path"', ({ path, component, branch }) => { const router = createRouter('', branch); diff --git a/spec/frontend/search/components/state_filter_spec.js b/spec/frontend/search/components/state_filter_spec.js new file mode 100644 index 00000000000..26344f2b592 --- /dev/null +++ b/spec/frontend/search/components/state_filter_spec.js @@ -0,0 +1,104 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import StateFilter from '~/search/state_filter/components/state_filter.vue'; +import { + FILTER_STATES, + SCOPES, + FILTER_STATES_BY_SCOPE, + FILTER_TEXT, +} from '~/search/state_filter/constants'; +import * as urlUtils from '~/lib/utils/url_utility'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn(), + setUrlParams: jest.fn(), +})); + +function createComponent(props = { scope: 'issues' }) { + return shallowMount(StateFilter, { + propsData: { + ...props, + }, + }); +} + +describe('StateFilter', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findGlDropdown = () => wrapper.find(GlDropdown); + const findGlDropdownItems = () => findGlDropdown().findAll(GlDropdownItem); + const findDropdownItemsText = () => findGlDropdownItems().wrappers.map(w => w.text()); + const firstDropDownItem = () => findGlDropdownItems().at(0); + + describe('template', () => { + describe.each` + scope | showStateDropdown + ${'issues'} | ${true} + ${'merge_requests'} | ${true} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`state dropdown`, ({ scope, showStateDropdown }) => { + beforeEach(() => { + wrapper = createComponent({ scope }); + }); + + it(`does${showStateDropdown ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findGlDropdown().exists()).toBe(showStateDropdown); + }); + }); + + describe.each` + state | label + ${FILTER_STATES.ANY.value} | ${FILTER_TEXT} + ${FILTER_STATES.OPEN.value} | ${FILTER_STATES.OPEN.label} + ${FILTER_STATES.CLOSED.value} | ${FILTER_STATES.CLOSED.label} + ${FILTER_STATES.MERGED.value} | ${FILTER_STATES.MERGED.label} + `(`filter text`, ({ state, label }) => { + describe(`when state is ${state}`, () => { + beforeEach(() => { + wrapper = createComponent({ scope: 'issues', state }); + }); + + it(`sets dropdown label to ${label}`, () => { + expect(findGlDropdown().attributes('text')).toBe(label); + }); + }); + }); + + describe('Filter options', () => { + it('renders a dropdown item for each filterOption', () => { + expect(findDropdownItemsText()).toStrictEqual( + FILTER_STATES_BY_SCOPE[SCOPES.ISSUES].map(v => { + return v.label; + }), + ); + }); + + it('clicking a dropdown item calls setUrlParams', () => { + const state = FILTER_STATES[Object.keys(FILTER_STATES)[0]].value; + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.setUrlParams).toHaveBeenCalledWith({ state }); + }); + + it('clicking a dropdown item calls visitUrl', () => { + firstDropDownItem().vm.$emit('click'); + + expect(urlUtils.visitUrl).toHaveBeenCalled(); + }); + }); + }); +}); diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index ee46dc015af..3240664f5aa 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -1,7 +1,6 @@ /* eslint-disable no-unused-expressions, consistent-return, no-param-reassign, default-case, no-return-assign */ import $ from 'jquery'; -import '~/gl_dropdown'; import AxiosMockAdapter from 'axios-mock-adapter'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import initSearchAutocomplete from '~/search_autocomplete'; @@ -215,7 +214,7 @@ describe('Search autocomplete dropdown', () => { function triggerAutocomplete() { return new Promise(resolve => { - const dropdown = widget.searchInput.data('glDropdown'); + const dropdown = widget.searchInput.data('deprecatedJQueryDropdown'); const filterCallback = dropdown.filter.options.callback; dropdown.filter.options.callback = jest.fn(data => { filterCallback(data); diff --git a/spec/frontend/serverless/utils.js b/spec/frontend/serverless/utils.js index ba451b7d573..4af3eda1ffb 100644 --- a/spec/frontend/serverless/utils.js +++ b/spec/frontend/serverless/utils.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const adjustMetricQuery = data => { const updatedMetric = data.metrics; diff --git a/spec/frontend/shortcuts_spec.js b/spec/frontend/shortcuts_spec.js index 3d16074154c..538b3afa50f 100644 --- a/spec/frontend/shortcuts_spec.js +++ b/spec/frontend/shortcuts_spec.js @@ -1,6 +1,18 @@ import $ from 'jquery'; +import { flatten } from 'lodash'; import Shortcuts from '~/behaviors/shortcuts/shortcuts'; +const mockMousetrap = { + bind: jest.fn(), + unbind: jest.fn(), +}; + +jest.mock('mousetrap', () => { + return jest.fn().mockImplementation(() => mockMousetrap); +}); + +jest.mock('mousetrap/plugins/pause/mousetrap-pause', () => {}); + describe('Shortcuts', () => { const fixtureName = 'snippets/show.html'; const createEvent = (type, target) => @@ -10,16 +22,16 @@ describe('Shortcuts', () => { preloadFixtures(fixtureName); - describe('toggleMarkdownPreview', () => { - beforeEach(() => { - loadFixtures(fixtureName); + beforeEach(() => { + loadFixtures(fixtureName); - jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus'); - jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus'); + jest.spyOn(document.querySelector('.js-new-note-form .js-md-preview-button'), 'focus'); + jest.spyOn(document.querySelector('.edit-note .js-md-preview-button'), 'focus'); - new Shortcuts(); // eslint-disable-line no-new - }); + new Shortcuts(); // eslint-disable-line no-new + }); + describe('toggleMarkdownPreview', () => { it('focuses preview button in form', () => { Shortcuts.toggleMarkdownPreview( createEvent('KeyboardEvent', document.querySelector('.js-new-note-form .js-note-text')), @@ -43,4 +55,63 @@ describe('Shortcuts', () => { expect(document.querySelector('.edit-note .js-md-preview-button').focus).toHaveBeenCalled(); }); }); + + describe('markdown shortcuts', () => { + let shortcuts; + + beforeEach(() => { + // Get all shortcuts specified with md-shortcuts attributes in the fixture. + // `shortcuts` will look something like this: + // [ + // [ 'mod+b' ], + // [ 'mod+i' ], + // [ 'mod+k' ] + // ] + shortcuts = $('.edit-note .js-md') + .map(function getShortcutsFromToolbarBtn() { + const mdShortcuts = $(this).data('md-shortcuts'); + + // jQuery.map() automatically unwraps arrays, so we + // have to double wrap the array to counteract this: + // https://stackoverflow.com/a/4875669/1063392 + return mdShortcuts ? [mdShortcuts] : undefined; + }) + .get(); + }); + + describe('initMarkdownEditorShortcuts', () => { + beforeEach(() => { + Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea')); + }); + + it('attaches a Mousetrap handler for every markdown shortcut specified with md-shortcuts', () => { + const expectedCalls = shortcuts.map(s => [s, expect.any(Function)]); + + expect(mockMousetrap.bind.mock.calls).toEqual(expectedCalls); + }); + + it('attaches a stopCallback that allows each markdown shortcut specified with md-shortcuts', () => { + flatten(shortcuts).forEach(s => { + expect(mockMousetrap.stopCallback(null, null, s)).toBe(false); + }); + }); + }); + + describe('removeMarkdownEditorShortcuts', () => { + it('does nothing if initMarkdownEditorShortcuts was not previous called', () => { + Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea')); + + expect(mockMousetrap.unbind.mock.calls).toEqual([]); + }); + + it('removes Mousetrap handlers for every markdown shortcut specified with md-shortcuts', () => { + Shortcuts.initMarkdownEditorShortcuts($('.edit-note textarea')); + Shortcuts.removeMarkdownEditorShortcuts($('.edit-note textarea')); + + const expectedCalls = shortcuts.map(s => [s]); + + expect(mockMousetrap.unbind.mock.calls).toEqual(expectedCalls); + }); + }); + }); }); diff --git a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap index 4c1ab4a499c..11ab1ca3aaa 100644 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap @@ -7,13 +7,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i > <div class="sidebar-collapsed-icon" - data-boundary="viewport" - data-container="body" - data-original-title="Not confidential" - data-placement="left" - title="" + title="Not confidential" > - <icon-stub + <gl-icon-stub aria-hidden="true" name="eye" size="16" @@ -38,7 +34,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i class="no-value sidebar-item-value" data-testid="not-confidential" > - <icon-stub + <gl-icon-stub aria-hidden="true" class="sidebar-item-icon inline" name="eye" @@ -59,13 +55,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i > <div class="sidebar-collapsed-icon" - data-boundary="viewport" - data-container="body" - data-original-title="Not confidential" - data-placement="left" - title="" + title="Not confidential" > - <icon-stub + <gl-icon-stub aria-hidden="true" name="eye" size="16" @@ -98,7 +90,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i class="no-value sidebar-item-value" data-testid="not-confidential" > - <icon-stub + <gl-icon-stub aria-hidden="true" class="sidebar-item-icon inline" name="eye" @@ -119,13 +111,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is > <div class="sidebar-collapsed-icon" - data-boundary="viewport" - data-container="body" - data-original-title="Confidential" - data-placement="left" - title="" + title="Confidential" > - <icon-stub + <gl-icon-stub aria-hidden="true" name="eye-slash" size="16" @@ -149,7 +137,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is <div class="value sidebar-item-value hide-collapsed" > - <icon-stub + <gl-icon-stub aria-hidden="true" class="sidebar-item-icon inline is-active" name="eye-slash" @@ -170,13 +158,9 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is > <div class="sidebar-collapsed-icon" - data-boundary="viewport" - data-container="body" - data-original-title="Confidential" - data-placement="left" - title="" + title="Confidential" > - <icon-stub + <gl-icon-stub aria-hidden="true" name="eye-slash" size="16" @@ -208,7 +192,7 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is <div class="value sidebar-item-value hide-collapsed" > - <icon-stub + <gl-icon-stub aria-hidden="true" class="sidebar-item-icon inline is-active" name="eye-slash" diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap index 0a12eb327de..42012841f0b 100644 --- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap @@ -13,7 +13,7 @@ exports[`SidebarTodo template renders component container element with proper da title="" type="button" > - <icon-stub + <gl-icon-stub class="todo-undone" name="todo-done" size="16" diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/assignees_spec.js index 3418680f8ea..d1810ada97a 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/assignees_spec.js @@ -1,5 +1,6 @@ import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; +import { GlIcon } from '@gitlab/ui'; import Assignee from '~/sidebar/components/assignees/assignees.vue'; import UsersMock from './mock_data'; import UsersMockHelper from '../helpers/user_mock_data_helper'; @@ -29,10 +30,12 @@ describe('Assignee component', () => { it('displays no assignee icon when collapsed', () => { createWrapper(); const collapsedChildren = findCollapsedChildren(); + const userIcon = collapsedChildren.at(0).find(GlIcon); expect(collapsedChildren.length).toBe(1); expect(collapsedChildren.at(0).attributes('aria-label')).toBe('None'); - expect(collapsedChildren.at(0).classes()).toContain('fa', 'fa-user'); + expect(userIcon.exists()).toBe(true); + expect(userIcon.props('name')).toBe('user'); }); it('displays only "None" when no users are assigned and the issue is read-only', () => { diff --git a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js index a1e19c1dd8e..907d6144415 100644 --- a/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js +++ b/spec/frontend/sidebar/components/assignees/collapsed_assignee_list_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import CollapsedAssigneeList from '~/sidebar/components/assignees/collapsed_assignee_list.vue'; import CollapsedAssignee from '~/sidebar/components/assignees/collapsed_assignee.vue'; @@ -20,7 +21,7 @@ describe('CollapsedAssigneeList component', () => { }); } - const findNoUsersIcon = () => wrapper.find('i[aria-label=None]'); + const findNoUsersIcon = () => wrapper.find(GlIcon); const findAvatarCounter = () => wrapper.find('.avatar-counter'); const findAssignees = () => wrapper.findAll(CollapsedAssignee); const getTooltipTitle = () => wrapper.attributes('title'); @@ -38,6 +39,7 @@ describe('CollapsedAssigneeList component', () => { it('has no users', () => { expect(findNoUsersIcon().exists()).toBe(true); + expect(findNoUsersIcon().props('name')).toBe('user'); }); }); diff --git a/spec/frontend/sidebar/components/severity/severity_spec.js b/spec/frontend/sidebar/components/severity/severity_spec.js new file mode 100644 index 00000000000..b6690f11d6b --- /dev/null +++ b/spec/frontend/sidebar/components/severity/severity_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import SeverityToken from '~/sidebar/components/severity/severity.vue'; +import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; + +describe('SeverityToken', () => { + let wrapper; + + function createComponent(props) { + wrapper = shallowMount(SeverityToken, { + propsData: { + ...props, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findIcon = () => wrapper.find(GlIcon); + + it('renders severity token for each severity type', () => { + Object.values(INCIDENT_SEVERITY).forEach(severity => { + createComponent({ severity }); + expect(findIcon().classes()).toContain(`icon-${severity.icon}`); + expect(findIcon().attributes('name')).toBe(`severity-${severity.icon}`); + expect(wrapper.text()).toBe(severity.label); + }); + }); + + it('renders only icon when `iconOnly` prop is set to `true`', () => { + const severity = INCIDENT_SEVERITY.CRITICAL; + createComponent({ severity, iconOnly: true }); + expect(findIcon().classes()).toContain(`icon-${severity.icon}`); + expect(findIcon().attributes('name')).toBe(`severity-${severity.icon}`); + expect(wrapper.text()).toBe(''); + }); + + describe('icon size', () => { + it('renders the icon in default size when other is not specified', () => { + const severity = INCIDENT_SEVERITY.HIGH; + createComponent({ severity }); + expect(findIcon().attributes('size')).toBe('12'); + }); + + it('renders the icon in provided size', () => { + const severity = INCIDENT_SEVERITY.HIGH; + const iconSize = 14; + createComponent({ severity, iconSize }); + expect(findIcon().attributes('size')).toBe(`${iconSize}`); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js new file mode 100644 index 00000000000..638d3706d12 --- /dev/null +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js @@ -0,0 +1,166 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlTooltip, GlSprintf } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; +import createFlash from '~/flash'; +import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; +import SeverityToken from '~/sidebar/components/severity/severity.vue'; +import updateIssuableSeverity from '~/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants'; + +jest.mock('~/flash'); + +describe('SidebarSeverity', () => { + let wrapper; + let mutate; + const projectPath = 'gitlab-org/gitlab-test'; + const iid = '1'; + const severity = 'CRITICAL'; + + function createComponent(props = {}) { + const propsData = { + projectPath, + iid, + issuableType: ISSUABLE_TYPES.INCIDENT, + initialSeverity: severity, + ...props, + }; + mutate = jest.fn(); + wrapper = shallowMount(SidebarSeverity, { + propsData, + mocks: { + $apollo: { + mutate, + }, + }, + stubs: { + GlSprintf, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + const findSeverityToken = () => wrapper.findAll(SeverityToken); + const findEditBtn = () => wrapper.find('[data-testid="editButton"]'); + const findDropdown = () => wrapper.find(GlDropdown); + const findCriticalSeverityDropdownItem = () => wrapper.find(GlDropdownItem); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findTooltip = () => wrapper.find(GlTooltip); + const findCollapsedSeverity = () => wrapper.find({ ref: 'severity' }); + + it('renders severity widget', () => { + expect(findEditBtn().exists()).toBe(true); + expect(findSeverityToken().exists()).toBe(true); + expect(findDropdown().exists()).toBe(true); + }); + + describe('Update severity', () => { + it('calls `$apollo.mutate` with `updateIssuableSeverity`', () => { + jest + .spyOn(wrapper.vm.$apollo, 'mutate') + .mockResolvedValueOnce({ data: { issueSetSeverity: { issue: { severity } } } }); + + findCriticalSeverityDropdownItem().vm.$emit('click'); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: updateIssuableSeverity, + variables: { + iid, + projectPath, + severity, + }, + }); + }); + + it('shows error alert when severity update fails ', () => { + const errorMsg = 'Something went wrong'; + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValueOnce(errorMsg); + findCriticalSeverityDropdownItem().vm.$emit('click'); + + setImmediate(() => { + expect(createFlash).toHaveBeenCalled(); + }); + }); + + it('shows loading icon while updating', async () => { + let resolvePromise; + wrapper.vm.$apollo.mutate = jest.fn( + () => + new Promise(resolve => { + resolvePromise = resolve; + }), + ); + findCriticalSeverityDropdownItem().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + expect(findLoadingIcon().exists()).toBe(true); + + resolvePromise(); + await waitForPromises(); + expect(findLoadingIcon().exists()).toBe(false); + }); + }); + + describe('Switch between collapsed/expanded view of the sidebar', () => { + const HIDDDEN_CLASS = 'gl-display-none'; + const SHOWN_CLASS = 'show'; + + describe('collapsed', () => { + it('should have collapsed icon class', () => { + expect(findCollapsedSeverity().classes('sidebar-collapsed-icon')).toBe(true); + }); + + it('should display only icon with a tooltip', () => { + expect( + findSeverityToken() + .at(0) + .attributes('icononly'), + ).toBe('true'); + expect( + findSeverityToken() + .at(0) + .attributes('iconsize'), + ).toBe('14'); + expect( + findTooltip() + .text() + .replace(/\s+/g, ' '), + ).toContain(`Severity: ${INCIDENT_SEVERITY[severity].label}`); + }); + + it('should expand the dropdown on collapsed icon click', async () => { + wrapper.vm.isDropdownShowing = false; + await wrapper.vm.$nextTick(); + expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true); + + findCollapsedSeverity().trigger('click'); + await wrapper.vm.$nextTick(); + expect(findDropdown().classes(SHOWN_CLASS)).toBe(true); + }); + }); + + describe('expanded', () => { + it('toggles dropdown with edit button', async () => { + wrapper.vm.isDropdownShowing = false; + await wrapper.vm.$nextTick(); + expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true); + + findEditBtn().vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(findDropdown().classes(SHOWN_CLASS)).toBe(true); + + findEditBtn().vm.$emit('click'); + await wrapper.vm.$nextTick(); + expect(findDropdown().classes(HIDDDEN_CLASS)).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/issuable_assignees_spec.js new file mode 100644 index 00000000000..aa930bd4198 --- /dev/null +++ b/spec/frontend/sidebar/issuable_assignees_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees.vue'; +import UncollapsedAssigneeList from '~/sidebar/components/assignees/uncollapsed_assignee_list.vue'; + +describe('IssuableAssignees', () => { + let wrapper; + + const createComponent = (props = { users: [] }) => { + wrapper = shallowMount(IssuableAssignees, { + provide: { + rootPath: '', + }, + propsData: { ...props }, + }); + }; + const findLabel = () => wrapper.find('[data-testid="assigneeLabel"'); + const findUncollapsedAssigneeList = () => wrapper.find(UncollapsedAssigneeList); + const findEmptyAssignee = () => wrapper.find('[data-testid="none"]'); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when no assignees are present', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders "None"', () => { + expect(findEmptyAssignee().text()).toBe('None'); + }); + + it('renders "0 assignees"', () => { + expect(findLabel().text()).toBe('0 Assignees'); + }); + }); + + describe('when assignees are present', () => { + it('renders UncollapsedAssigneesList', () => { + createComponent({ users: [{ id: 1 }] }); + + expect(findUncollapsedAssigneeList().exists()).toBe(true); + }); + + it.each` + assignees | expected + ${[{ id: 1 }]} | ${'Assignee'} + ${[{ id: 1 }, { id: 2 }]} | ${'2 Assignees'} + `( + 'when assignees have a length of $assignees.length, it renders $expected', + ({ assignees, expected }) => { + createComponent({ users: assignees }); + + expect(findLabel().text()).toBe(expected); + }, + ); + }); +}); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/participants_spec.js index ebe94582588..93c9b3b84c3 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/participants_spec.js @@ -37,7 +37,7 @@ describe('Participants', () => { loading: true, }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); it('does not show loading spinner not loading', () => { @@ -45,7 +45,7 @@ describe('Participants', () => { loading: false, }); - expect(wrapper.contains(GlLoadingIcon)).toBe(false); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); }); it('shows participant count when given', () => { @@ -74,7 +74,7 @@ describe('Participants', () => { loading: true, }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); it('when only showing visible participants, shows an avatar only for each participant under the limit', () => { @@ -196,11 +196,11 @@ describe('Participants', () => { }); it('does not show sidebar collapsed icon', () => { - expect(wrapper.contains('.sidebar-collapsed-icon')).toBe(false); + expect(wrapper.find('.sidebar-collapsed-icon').exists()).toBe(false); }); it('does not show participants label title', () => { - expect(wrapper.contains('.title')).toBe(false); + expect(wrapper.find('.title').exists()).toBe(false); }); }); }); diff --git a/spec/frontend/sidebar/sidebar_labels_spec.js b/spec/frontend/sidebar/sidebar_labels_spec.js new file mode 100644 index 00000000000..29333a344e1 --- /dev/null +++ b/spec/frontend/sidebar/sidebar_labels_spec.js @@ -0,0 +1,124 @@ +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import Vuex from 'vuex'; +import { + mockLabels, + mockRegularLabel, +} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; +import axios from '~/lib/utils/axios_utils'; +import SidebarLabels from '~/sidebar/components/labels/sidebar_labels.vue'; +import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; +import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('sidebar labels', () => { + let axiosMock; + let wrapper; + + const store = new Vuex.Store(labelsSelectModule()); + + const defaultProps = { + allowLabelCreate: true, + allowLabelEdit: true, + allowScopedLabels: true, + canEdit: true, + iid: '1', + initiallySelectedLabels: mockLabels, + issuableType: 'issue', + labelsFetchPath: '/gitlab-org/gitlab-test/-/labels.json?include_ancestor_groups=true', + labelsManagePath: '/gitlab-org/gitlab-test/-/labels', + labelsUpdatePath: '/gitlab-org/gitlab-test/-/issues/1.json', + projectIssuesPath: '/gitlab-org/gitlab-test/-/issues', + projectPath: 'gitlab-org/gitlab-test', + }; + + const findLabelsSelect = () => wrapper.find(LabelsSelect); + + const mountComponent = () => { + wrapper = shallowMount(SidebarLabels, { + localVue, + provide: { + ...defaultProps, + }, + store, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + axiosMock.restore(); + }); + + describe('LabelsSelect props', () => { + beforeEach(() => { + mountComponent(); + }); + + it('are as expected', () => { + expect(findLabelsSelect().props()).toMatchObject({ + allowLabelCreate: defaultProps.allowLabelCreate, + allowLabelEdit: defaultProps.allowLabelEdit, + allowMultiselect: true, + allowScopedLabels: defaultProps.allowScopedLabels, + footerCreateLabelTitle: 'Create project label', + footerManageLabelTitle: 'Manage project labels', + labelsCreateTitle: 'Create project label', + labelsFetchPath: defaultProps.labelsFetchPath, + labelsFilterBasePath: defaultProps.projectIssuesPath, + labelsManagePath: defaultProps.labelsManagePath, + labelsSelectInProgress: false, + selectedLabels: defaultProps.initiallySelectedLabels, + variant: DropdownVariant.Sidebar, + }); + }); + }); + + describe('when labels are changed', () => { + beforeEach(() => { + mountComponent(); + }); + + it('makes an API call to update labels', async () => { + const labels = [ + { + ...mockRegularLabel, + set: false, + }, + { + id: 40, + title: 'Security', + color: '#ddd', + text_color: '#fff', + set: true, + }, + { + id: 55, + title: 'Tooling', + color: '#ddd', + text_color: '#fff', + set: false, + }, + ]; + + findLabelsSelect().vm.$emit('updateSelectedLabels', labels); + + await axios.waitForAll(); + + const expected = { + [defaultProps.issuableType]: { + label_ids: [27, 28, 40], + }, + }; + + expect(axiosMock.history.put[0].data).toEqual(JSON.stringify(expected)); + }); + }); +}); diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/sidebar_move_issue_spec.js index db0d3e06272..ad919f69546 100644 --- a/spec/frontend/sidebar/sidebar_move_issue_spec.js +++ b/spec/frontend/sidebar/sidebar_move_issue_spec.js @@ -68,12 +68,10 @@ describe('SidebarMoveIssue', () => { }); describe('initDropdown', () => { - it('should initialize the gl_dropdown', () => { - jest.spyOn($.fn, 'glDropdown').mockImplementation(() => {}); - + it('should initialize the deprecatedJQueryDropdown', () => { test.sidebarMoveIssue.initDropdown(); - expect($.fn.glDropdown).toHaveBeenCalled(); + expect(test.sidebarMoveIssue.$dropdownToggle.data('deprecatedJQueryDropdown')).toBeTruthy(); }); it('escapes html from project name', done => { diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/subscriptions_spec.js index cce35666985..dddb9c2bba9 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/subscriptions_spec.js @@ -100,7 +100,7 @@ describe('Subscriptions', () => { }); it('does not render the toggle button', () => { - expect(wrapper.contains('.js-issuable-subscribe-button')).toBe(false); + expect(wrapper.find('.js-issuable-subscribe-button').exists()).toBe(false); }); }); }); diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js index e56a78989eb..b0e94f16dd7 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/todo_spec.js @@ -1,8 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import { GlLoadingIcon } from '@gitlab/ui'; +import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; import SidebarTodos from '~/sidebar/components/todo_toggle/todo.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const defaultProps = { issuableId: 1, @@ -45,11 +44,11 @@ describe('SidebarTodo', () => { expect( wrapper - .find(Icon) + .find(GlIcon) .classes() .join(' '), ).toStrictEqual(iconClass); - expect(wrapper.find(Icon).props('name')).toStrictEqual(icon); + expect(wrapper.find(GlIcon).props('name')).toStrictEqual(icon); expect(wrapper.find('button').text()).toBe(label); }, ); @@ -82,7 +81,7 @@ describe('SidebarTodo', () => { it('renders button icon when `collapsed` prop is `true`', () => { createComponent({ collapsed: true }); - expect(wrapper.find(Icon).props('name')).toBe('todo-done'); + expect(wrapper.find(GlIcon).props('name')).toBe('todo-done'); }); it('renders loading icon when `isActionActive` prop is true', () => { @@ -94,7 +93,7 @@ describe('SidebarTodo', () => { it('hides button icon when `isActionActive` prop is true', () => { createComponent({ collapsed: true, isActionActive: true }); - expect(wrapper.find(Icon).isVisible()).toBe(false); + expect(wrapper.find(GlIcon).isVisible()).toBe(false); }); }); }); diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js index ad69a91fe89..208d2fea804 100644 --- a/spec/frontend/snippet/snippet_bundle_spec.js +++ b/spec/frontend/snippet/snippet_bundle_spec.js @@ -15,11 +15,13 @@ describe('Snippet editor', () => { const updatedMockContent = 'New Foo Bar'; const mockEditor = { - createInstance: jest.fn(), updateModelLanguage: jest.fn(), getValue: jest.fn().mockReturnValueOnce(updatedMockContent), }; - Editor.mockImplementation(() => mockEditor); + const createInstance = jest.fn().mockImplementation(() => ({ ...mockEditor })); + Editor.mockImplementation(() => ({ + createInstance, + })); function setUpFixture(name, content) { setHTMLFixture(` @@ -56,7 +58,7 @@ describe('Snippet editor', () => { }); it('correctly initializes Editor', () => { - expect(mockEditor.createInstance).toHaveBeenCalledWith({ + expect(createInstance).toHaveBeenCalledWith({ el: editorEl, blobPath: mockName, blobContent: mockContent, diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap index 6020d595e3f..3b101e9e815 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_description_edit_spec.js.snap @@ -41,7 +41,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = > <textarea aria-label="Description" - class="note-textarea js-gfm-input js-autosize markdown-area" + class="note-textarea js-gfm-input js-autosize markdown-area js-gfm-input-initialized" data-qa-selector="snippet_description_field" data-supports-quick-actions="false" dir="auto" @@ -63,8 +63,8 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" href="#" > - <icon-stub - name="screen-normal" + <gl-icon-stub + name="minimize" size="16" /> </a> diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap index be75a5bfbdc..8446f0f50c4 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_visibility_edit_spec.js.snap @@ -20,6 +20,7 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = </label> <gl-form-group-stub + class="gl-mb-0" id="visibility-level-setting" > <gl-form-radio-group-stub @@ -90,5 +91,12 @@ exports[`Snippet Visibility Edit component rendering matches the snapshot 1`] = </gl-form-radio-stub> </gl-form-radio-group-stub> </gl-form-group-stub> + + <div + class="text-muted" + data-testid="restricted-levels-info" + > + <!----> + </div> </div> `; diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index ebab6aa84f6..b6abb9f389a 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -47,6 +47,8 @@ const createTestSnippet = () => ({ describe('Snippet Edit app', () => { let wrapper; + const relativeUrlRoot = '/foo/'; + const originalRelativeUrlRoot = gon.relative_url_root; const mutationTypes = { RESOLVE: jest.fn().mockResolvedValue({ @@ -100,16 +102,25 @@ describe('Snippet Edit app', () => { markdownDocsPath: 'http://docs.foo.bar', ...props, }, + data() { + return { + snippet: { + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, + }, + }; + }, }); } beforeEach(() => { + gon.relative_url_root = relativeUrlRoot; jest.spyOn(urlUtils, 'redirectTo').mockImplementation(); }); afterEach(() => { wrapper.destroy(); wrapper = null; + gon.relative_url_root = originalRelativeUrlRoot; }); const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); @@ -164,10 +175,10 @@ describe('Snippet Edit app', () => { props => { createComponent(props); - expect(wrapper.contains(TitleField)).toBe(true); - expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true); - expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true); - expect(wrapper.contains(FormFooterActions)).toBe(true); + expect(wrapper.find(TitleField).exists()).toBe(true); + expect(wrapper.find(SnippetDescriptionEdit).exists()).toBe(true); + expect(wrapper.find(SnippetVisibilityEdit).exists()).toBe(true); + expect(wrapper.find(FormFooterActions).exists()).toBe(true); expect(findBlobActions().exists()).toBe(true); }, ); @@ -196,8 +207,8 @@ describe('Snippet Edit app', () => { it.each` projectPath | snippetArg | expectation - ${''} | ${[]} | ${'/-/snippets'} - ${'project/path'} | ${[]} | ${'/project/path/-/snippets'} + ${''} | ${[]} | ${urlUtils.joinPaths('/', relativeUrlRoot, '-', 'snippets')} + ${'project/path'} | ${[]} | ${urlUtils.joinPaths('/', relativeUrlRoot, 'project/path/-', 'snippets')} ${''} | ${[createTestSnippet()]} | ${TEST_WEB_URL} ${'project/path'} | ${[createTestSnippet()]} | ${TEST_WEB_URL} `( diff --git a/spec/frontend/snippets/components/embed_dropdown_spec.js b/spec/frontend/snippets/components/embed_dropdown_spec.js new file mode 100644 index 00000000000..8eb44965692 --- /dev/null +++ b/spec/frontend/snippets/components/embed_dropdown_spec.js @@ -0,0 +1,70 @@ +import { escape as esc } from 'lodash'; +import { mount } from '@vue/test-utils'; +import { GlFormInputGroup } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; +import EmbedDropdown from '~/snippets/components/embed_dropdown.vue'; + +const TEST_URL = `${TEST_HOST}/test/no">'xss`; + +describe('snippets/components/embed_dropdown', () => { + let wrapper; + + const createComponent = () => { + wrapper = mount(EmbedDropdown, { + propsData: { + url: TEST_URL, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findSectionsData = () => { + const sections = []; + let current = {}; + + wrapper.findAll('[data-testid="header"],[data-testid="input"]').wrappers.forEach(x => { + const type = x.attributes('data-testid'); + + if (type === 'header') { + current = { + header: x.text(), + }; + + sections.push(current); + } else { + const value = x.find(GlFormInputGroup).props('value'); + const copyValue = x.find('button[title="Copy"]').attributes('data-clipboard-text'); + + Object.assign(current, { + value, + copyValue, + }); + } + }); + + return sections; + }; + + it('renders dropdown items', () => { + createComponent(); + + const embedValue = `<script src="${esc(TEST_URL)}.js"></script>`; + + expect(findSectionsData()).toEqual([ + { + header: 'Embed', + value: embedValue, + copyValue: embedValue, + }, + { + header: 'Share', + value: TEST_URL, + copyValue: TEST_URL, + }, + ]); + }); +}); diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index 8cccbb83d54..b5ab7def753 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -2,7 +2,7 @@ import { GlLoadingIcon } from '@gitlab/ui'; import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import { shallowMount } from '@vue/test-utils'; import SnippetApp from '~/snippets/components/show.vue'; -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; +import EmbedDropdown from '~/snippets/components/embed_dropdown.vue'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; import SnippetTitle from '~/snippets/components/snippet_title.vue'; import SnippetBlob from '~/snippets/components/snippet_blob_view.vue'; @@ -57,7 +57,7 @@ describe('Snippet view app', () => { expect(wrapper.find(SnippetTitle).exists()).toBe(true); }); - it('renders embeddable component if visibility allows', () => { + it('renders embed dropdown component if visibility allows', () => { createComponent({ data: { snippet: { @@ -66,7 +66,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.contains(BlobEmbeddable)).toBe(true); + expect(wrapper.find(EmbedDropdown).exists()).toBe(true); }); it('renders correct snippet-blob components', () => { @@ -88,7 +88,7 @@ describe('Snippet view app', () => { ${SNIPPET_VISIBILITY_PRIVATE} | ${'not render'} | ${false} ${'foo'} | ${'not render'} | ${false} ${SNIPPET_VISIBILITY_PUBLIC} | ${'render'} | ${true} - `('does $condition blob-embeddable by default', ({ visibilityLevel, isRendered }) => { + `('does $condition embed-dropdown by default', ({ visibilityLevel, isRendered }) => { createComponent({ data: { snippet: { @@ -97,7 +97,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.contains(BlobEmbeddable)).toBe(isRendered); + expect(wrapper.find(EmbedDropdown).exists()).toBe(isRendered); }); }); @@ -119,7 +119,7 @@ describe('Snippet view app', () => { }, }, }); - expect(wrapper.contains(CloneDropdownButton)).toBe(isRendered); + expect(wrapper.find(CloneDropdownButton).exists()).toBe(isRendered); }, ); }); diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index 188f9ae5cf1..fc4da46d722 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -17,6 +17,7 @@ const TEST_PATH = 'foo/bar/test.md'; const TEST_RAW_PATH = '/gitlab/raw/path/to/blob/7'; const TEST_FULL_PATH = joinPaths(TEST_HOST, TEST_RAW_PATH); const TEST_CONTENT = 'Lorem ipsum dolar sit amet,\nconsectetur adipiscing elit.'; +const TEST_JSON_CONTENT = '{"abc":"lorem ipsum"}'; const TEST_BLOB = { id: TEST_ID, @@ -66,7 +67,7 @@ describe('Snippet Blob Edit component', () => { }); describe('with not loaded blob', () => { - beforeEach(async () => { + beforeEach(() => { createComponent(); }); @@ -100,6 +101,20 @@ describe('Snippet Blob Edit component', () => { }); }); + describe('with unloaded blob and JSON content', () => { + beforeEach(() => { + axiosMock.onGet(TEST_FULL_PATH).reply(200, TEST_JSON_CONTENT); + createComponent(); + }); + + // This checks against this issue https://gitlab.com/gitlab-org/gitlab/-/issues/241199 + it('emits raw content', async () => { + await waitForPromises(); + + expect(getLastUpdatedArgs()).toEqual({ content: TEST_JSON_CONTENT }); + }); + }); + describe('with error', () => { beforeEach(() => { axiosMock.reset(); diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index da8cb2e6a8d..5836de1fdbe 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -5,6 +5,7 @@ import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; import waitForPromises from 'helpers/wait_for_promises'; import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; import SnippetHeader from '~/snippets/components/snippet_header.vue'; +import { differenceInMilliseconds } from '~/lib/utils/datetime_utility'; describe('Snippet header component', () => { let wrapper; @@ -14,6 +15,7 @@ describe('Snippet header component', () => { let errorMsg; let err; + const originalRelativeUrlRoot = gon.relative_url_root; function createComponent({ loading = false, @@ -50,6 +52,7 @@ describe('Snippet header component', () => { } beforeEach(() => { + gon.relative_url_root = '/foo/'; snippet = { id: 'gid://gitlab/PersonalSnippet/50', title: 'The property of Thor', @@ -65,7 +68,7 @@ describe('Snippet header component', () => { name: 'Thor Odinson', }, blobs: [Blob], - createdAt: new Date(Date.now() - 32 * 24 * 3600 * 1000).toISOString(), + createdAt: new Date(differenceInMilliseconds(32 * 24 * 3600 * 1000)).toISOString(), }; mutationVariables = { @@ -86,6 +89,7 @@ describe('Snippet header component', () => { afterEach(() => { wrapper.destroy(); + gon.relative_url_root = originalRelativeUrlRoot; }); it('renders itself', () => { @@ -213,7 +217,7 @@ describe('Snippet header component', () => { it('redirects to dashboard/snippets for personal snippet', () => { return createDeleteSnippet().then(() => { expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled(); - expect(window.location.pathname).toBe('dashboard/snippets'); + expect(window.location.pathname).toBe(`${gon.relative_url_root}dashboard/snippets`); }); }); diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js index a8df13787a5..3919e4d7993 100644 --- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js @@ -1,31 +1,55 @@ import { GlFormRadio, GlIcon, GlFormRadioGroup, GlLink } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; +import { defaultSnippetVisibilityLevels } from '~/snippets/utils/blob'; import { SNIPPET_VISIBILITY, SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC, + SNIPPET_LEVELS_RESTRICTED, + SNIPPET_LEVELS_DISABLED, } from '~/snippets/constants'; describe('Snippet Visibility Edit component', () => { let wrapper; const defaultHelpLink = '/foo/bar'; const defaultVisibilityLevel = 'private'; - - function createComponent(propsData = {}, deep = false) { + const defaultVisibility = defaultSnippetVisibilityLevels([0, 10, 20]); + + function createComponent({ + propsData = {}, + visibilityLevels = defaultVisibility, + multipleLevelsRestricted = false, + deep = false, + } = {}) { const method = deep ? mount : shallowMount; + const $apollo = { + queries: { + defaultVisibility: { + loading: false, + }, + }, + }; + wrapper = method.call(this, SnippetVisibilityEdit, { + mock: { $apollo }, propsData: { helpLink: defaultHelpLink, isProjectSnippet: false, value: defaultVisibilityLevel, ...propsData, }, + data() { + return { + visibilityLevels, + multipleLevelsRestricted, + }; + }, }); } - const findLabel = () => wrapper.find('label'); + const findLink = () => wrapper.find('label').find(GlLink); const findRadios = () => wrapper.find(GlFormRadioGroup).findAll(GlFormRadio); const findRadiosData = () => findRadios().wrappers.map(x => { @@ -47,56 +71,84 @@ describe('Snippet Visibility Edit component', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('renders visibility options', () => { - createComponent({}, true); + it('renders label help link', () => { + createComponent(); + + expect(findLink().attributes('href')).toBe(defaultHelpLink); + }); + + it('when helpLink is not defined, does not render label help link', () => { + createComponent({ propsData: { helpLink: null } }); - expect(findRadiosData()).toEqual([ - { + expect(findLink().exists()).toBe(false); + }); + + describe('Visibility options', () => { + const findRestrictedInfo = () => wrapper.find('[data-testid="restricted-levels-info"]'); + const RESULTING_OPTIONS = { + 0: { value: SNIPPET_VISIBILITY_PRIVATE, icon: SNIPPET_VISIBILITY.private.icon, text: SNIPPET_VISIBILITY.private.label, description: SNIPPET_VISIBILITY.private.description, }, - { + 10: { value: SNIPPET_VISIBILITY_INTERNAL, icon: SNIPPET_VISIBILITY.internal.icon, text: SNIPPET_VISIBILITY.internal.label, description: SNIPPET_VISIBILITY.internal.description, }, - { + 20: { value: SNIPPET_VISIBILITY_PUBLIC, icon: SNIPPET_VISIBILITY.public.icon, text: SNIPPET_VISIBILITY.public.label, description: SNIPPET_VISIBILITY.public.description, }, - ]); - }); - - it('when project snippet, renders special private description', () => { - createComponent({ isProjectSnippet: true }, true); + }; - expect(findRadiosData()[0]).toEqual({ - value: SNIPPET_VISIBILITY_PRIVATE, - icon: SNIPPET_VISIBILITY.private.icon, - text: SNIPPET_VISIBILITY.private.label, - description: SNIPPET_VISIBILITY.private.description_project, + it.each` + levels | resultOptions + ${undefined} | ${[]} + ${''} | ${[]} + ${[]} | ${[]} + ${[0]} | ${[RESULTING_OPTIONS[0]]} + ${[0, 10]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[10]]} + ${[0, 10, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]} + ${[0, 20]} | ${[RESULTING_OPTIONS[0], RESULTING_OPTIONS[20]]} + ${[10, 20]} | ${[RESULTING_OPTIONS[10], RESULTING_OPTIONS[20]]} + `('renders correct visibility options for $levels', ({ levels, resultOptions }) => { + createComponent({ visibilityLevels: defaultSnippetVisibilityLevels(levels), deep: true }); + expect(findRadiosData()).toEqual(resultOptions); }); - }); - it('renders label help link', () => { - createComponent(); - - expect( - findLabel() - .find(GlLink) - .attributes('href'), - ).toBe(defaultHelpLink); - }); + it.each` + levels | levelsRestricted | resultText + ${[]} | ${false} | ${SNIPPET_LEVELS_DISABLED} + ${[]} | ${true} | ${SNIPPET_LEVELS_DISABLED} + ${[0]} | ${true} | ${SNIPPET_LEVELS_RESTRICTED} + ${[0]} | ${false} | ${''} + ${[0, 10, 20]} | ${false} | ${''} + `( + 'renders correct information about restricted visibility levels for $levels', + ({ levels, levelsRestricted, resultText }) => { + createComponent({ + visibilityLevels: defaultSnippetVisibilityLevels(levels), + multipleLevelsRestricted: levelsRestricted, + }); + expect(findRestrictedInfo().text()).toBe(resultText); + }, + ); - it('when helpLink is not defined, does not render label help link', () => { - createComponent({ helpLink: null }); + it('when project snippet, renders special private description', () => { + createComponent({ propsData: { isProjectSnippet: true }, deep: true }); - expect(findLabel().contains(GlLink)).toBe(false); + expect(findRadiosData()[0]).toEqual({ + value: SNIPPET_VISIBILITY_PRIVATE, + icon: SNIPPET_VISIBILITY.private.icon, + text: SNIPPET_VISIBILITY.private.label, + description: SNIPPET_VISIBILITY.private.description_project, + }); + }); }); }); @@ -104,7 +156,7 @@ describe('Snippet Visibility Edit component', () => { it('pre-selects correct option in the list', () => { const value = SNIPPET_VISIBILITY_INTERNAL; - createComponent({ value }); + createComponent({ propsData: { value } }); expect(wrapper.find(GlFormRadioGroup).attributes('checked')).toBe(value); }); diff --git a/spec/frontend/static_site_editor/components/edit_area_spec.js b/spec/frontend/static_site_editor/components/edit_area_spec.js index f4be911171e..7e90b53dd07 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -6,11 +6,13 @@ import { EDITOR_TYPES } from '~/vue_shared/components/rich_content_editor/consta import EditArea from '~/static_site_editor/components/edit_area.vue'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; import EditHeader from '~/static_site_editor/components/edit_header.vue'; +import EditDrawer from '~/static_site_editor/components/edit_drawer.vue'; import UnsavedChangesConfirmDialog from '~/static_site_editor/components/unsaved_changes_confirm_dialog.vue'; import { sourceContentTitle as title, - sourceContent as content, + sourceContentYAML as content, + sourceContentHeaderObjYAML as headerSettings, sourceContentBody as body, returnUrl, } from '../mock_data'; @@ -36,6 +38,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }; const findEditHeader = () => wrapper.find(EditHeader); + const findEditDrawer = () => wrapper.find(EditDrawer); const findRichContentEditor = () => wrapper.find(RichContentEditor); const findPublishToolbar = () => wrapper.find(PublishToolbar); const findUnsavedChangesConfirmDialog = () => wrapper.find(UnsavedChangesConfirmDialog); @@ -46,6 +49,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { afterEach(() => { wrapper.destroy(); + wrapper = null; }); it('renders edit header', () => { @@ -53,6 +57,10 @@ describe('~/static_site_editor/components/edit_area.vue', () => { expect(findEditHeader().props('title')).toBe(title); }); + it('renders edit drawer', () => { + expect(findEditDrawer().exists()).toBe(true); + }); + it('renders rich content editor with a format pass', () => { expect(findRichContentEditor().exists()).toBe(true); expect(findRichContentEditor().props('content')).toBe(formattedBody); @@ -81,7 +89,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { it('updates parsedSource with new content', () => { const newContent = 'New content'; - const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'sync'); + const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'syncContent'); findRichContentEditor().vm.$emit('input', newContent); @@ -148,11 +156,88 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }); }); + describe('when content has front matter', () => { + it('renders a closed edit drawer', () => { + expect(findEditDrawer().exists()).toBe(true); + expect(findEditDrawer().props('isOpen')).toBe(false); + }); + + it('opens the edit drawer', () => { + findPublishToolbar().vm.$emit('editSettings'); + + return wrapper.vm.$nextTick().then(() => { + expect(findEditDrawer().props('isOpen')).toBe(true); + }); + }); + + it('closes the edit drawer', () => { + findEditDrawer().vm.$emit('close'); + + return wrapper.vm.$nextTick().then(() => { + expect(findEditDrawer().props('isOpen')).toBe(false); + }); + }); + + it('forwards the matter settings when the drawer is open', () => { + findPublishToolbar().vm.$emit('editSettings'); + + jest.spyOn(wrapper.vm.parsedSource, 'matter').mockReturnValueOnce(headerSettings); + + return wrapper.vm.$nextTick().then(() => { + expect(findEditDrawer().props('settings')).toEqual(headerSettings); + }); + }); + + it('enables toolbar submit button', () => { + expect(findPublishToolbar().props('hasSettings')).toBe(true); + }); + + it('syncs matter changes regardless of edit mode', () => { + const newSettings = { title: 'test' }; + const spySyncParsedSource = jest.spyOn(wrapper.vm.parsedSource, 'syncMatter'); + + findEditDrawer().vm.$emit('updateSettings', newSettings); + + expect(spySyncParsedSource).toHaveBeenCalledWith(newSettings); + }); + + it('syncs matter changes to content in markdown mode', () => { + wrapper.setData({ editorMode: EDITOR_TYPES.markdown }); + + const newSettings = { title: 'test' }; + + findEditDrawer().vm.$emit('updateSettings', newSettings); + + return wrapper.vm.$nextTick().then(() => { + expect(findRichContentEditor().props('content')).toContain('title: test'); + }); + }); + }); + + describe('when content lacks front matter', () => { + beforeEach(() => { + buildWrapper({ content: body }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('does not render edit drawer', () => { + expect(findEditDrawer().exists()).toBe(false); + }); + + it('does not enable toolbar submit button', () => { + expect(findPublishToolbar().props('hasSettings')).toBe(false); + }); + }); + describe('when content is submitted', () => { it('should format the content', () => { findPublishToolbar().vm.$emit('submit', content); expect(wrapper.emitted('submit')[0][0].content).toBe(`${content} format-pass format-pass`); + expect(wrapper.emitted('submit').length).toBe(1); }); }); }); diff --git a/spec/frontend/static_site_editor/components/edit_drawer_spec.js b/spec/frontend/static_site_editor/components/edit_drawer_spec.js new file mode 100644 index 00000000000..c47eef59997 --- /dev/null +++ b/spec/frontend/static_site_editor/components/edit_drawer_spec.js @@ -0,0 +1,68 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlDrawer } from '@gitlab/ui'; + +import EditDrawer from '~/static_site_editor/components/edit_drawer.vue'; +import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue'; + +describe('~/static_site_editor/components/edit_drawer.vue', () => { + let wrapper; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(EditDrawer, { + propsData: { + isOpen: false, + settings: { title: 'Some title' }, + ...propsData, + }, + }); + }; + + const findFrontMatterControls = () => wrapper.find(FrontMatterControls); + const findGlDrawer = () => wrapper.find(GlDrawer); + + beforeEach(() => { + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders the GlDrawer', () => { + expect(findGlDrawer().exists()).toBe(true); + }); + + it('renders the FrontMatterControls', () => { + expect(findFrontMatterControls().exists()).toBe(true); + }); + + it('forwards the settings to FrontMatterControls', () => { + expect(findFrontMatterControls().props('settings')).toBe(wrapper.props('settings')); + }); + + it('is closed by default', () => { + expect(findGlDrawer().props('open')).toBe(false); + }); + + it('can open', () => { + buildWrapper({ isOpen: true }); + + expect(findGlDrawer().props('open')).toBe(true); + }); + + it.each` + event | payload | finderFn + ${'close'} | ${undefined} | ${findGlDrawer} + ${'updateSettings'} | ${{ some: 'data' }} | ${findFrontMatterControls} + `( + 'forwards the emitted $event event from the $finderFn with $payload', + ({ event, payload, finderFn }) => { + finderFn().vm.$emit(event, payload); + + expect(wrapper.emitted(event)[0][0]).toBe(payload); + expect(wrapper.emitted(event).length).toBe(1); + }, + ); +}); diff --git a/spec/frontend/static_site_editor/components/front_matter_controls_spec.js b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js new file mode 100644 index 00000000000..82e8fad643e --- /dev/null +++ b/spec/frontend/static_site_editor/components/front_matter_controls_spec.js @@ -0,0 +1,78 @@ +import { shallowMount } from '@vue/test-utils'; + +import { GlFormGroup } from '@gitlab/ui'; +import { humanize } from '~/lib/utils/text_utility'; + +import FrontMatterControls from '~/static_site_editor/components/front_matter_controls.vue'; + +describe('~/static_site_editor/components/front_matter_controls.vue', () => { + let wrapper; + + // TODO Refactor and update `sourceContentHeaderObjYAML` in mock_data when !41230 lands + const settings = { + layout: 'handbook-page-toc', + title: 'Handbook', + twitter_image: '/images/tweets/handbook-gitlab.png', + suppress_header: true, + extra_css: ['sales-and-free-trial-common.css', 'form-to-resource.css'], + }; + + const buildWrapper = (propsData = {}) => { + wrapper = shallowMount(FrontMatterControls, { + propsData: { + settings, + ...propsData, + }, + }); + }; + + beforeEach(() => { + buildWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render only the supported GlFormGroup types', () => { + expect(wrapper.findAll(GlFormGroup)).toHaveLength(3); + }); + + it.each` + key + ${'layout'} + ${'title'} + ${'twitter_image'} + `('renders field when key is $key', ({ key }) => { + const glFormGroup = wrapper.find(`#sse-front-matter-form-group-${key}`); + const glFormInput = wrapper.find(`#sse-front-matter-control-${key}`); + + expect(glFormGroup.exists()).toBe(true); + expect(glFormGroup.attributes().label).toBe(humanize(key)); + + expect(glFormInput.exists()).toBe(true); + expect(glFormInput.attributes().value).toBe(settings[key]); + }); + + it.each` + key + ${'suppress_header'} + ${'extra_css'} + `('does not render field when key is $key', ({ key }) => { + const glFormInput = wrapper.find(`#sse-front-matter-control-${key}`); + + expect(glFormInput.exists()).toBe(false); + }); + + it('emits updated settings when nested control updates', () => { + const elId = `#sse-front-matter-control-title`; + const glFormInput = wrapper.find(elId); + const newTitle = 'New title'; + + glFormInput.vm.$emit('input', newTitle); + + const newSettings = { ...settings, title: newTitle }; + + expect(wrapper.emitted('updateSettings')[0][0]).toMatchObject(newSettings); + }); +}); diff --git a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js index 5428ed23266..9ba7e4a94d1 100644 --- a/spec/frontend/static_site_editor/components/publish_toolbar_spec.js +++ b/spec/frontend/static_site_editor/components/publish_toolbar_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton } from '@gitlab/ui'; import PublishToolbar from '~/static_site_editor/components/publish_toolbar.vue'; @@ -11,6 +10,7 @@ describe('Static Site Editor Toolbar', () => { const buildWrapper = (propsData = {}) => { wrapper = shallowMount(PublishToolbar, { propsData: { + hasSettings: false, saveable: false, ...propsData, }, @@ -18,7 +18,8 @@ describe('Static Site Editor Toolbar', () => { }; const findReturnUrlLink = () => wrapper.find({ ref: 'returnUrlLink' }); - const findSaveChangesButton = () => wrapper.find(GlButton); + const findSaveChangesButton = () => wrapper.find({ ref: 'submit' }); + const findEditSettingsButton = () => wrapper.find({ ref: 'settings' }); beforeEach(() => { buildWrapper(); @@ -28,6 +29,10 @@ describe('Static Site Editor Toolbar', () => { wrapper.destroy(); }); + it('does not render Settings button', () => { + expect(findEditSettingsButton().exists()).toBe(false); + }); + it('renders Submit Changes button', () => { expect(findSaveChangesButton().exists()).toBe(true); }); @@ -51,6 +56,14 @@ describe('Static Site Editor Toolbar', () => { expect(findReturnUrlLink().attributes('href')).toBe(returnUrl); }); + describe('when providing settings CTA', () => { + it('enables Submit Changes button', () => { + buildWrapper({ hasSettings: true }); + + expect(findEditSettingsButton().exists()).toBe(true); + }); + }); + describe('when saveable', () => { it('enables Submit Changes button', () => { buildWrapper({ saveable: true }); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js index 8504d09e0f1..24651543650 100644 --- a/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js +++ b/spec/frontend/static_site_editor/graphql/resolvers/file_spec.js @@ -5,7 +5,7 @@ import { projectId, sourcePath, sourceContentTitle as title, - sourceContent as content, + sourceContentYAML as content, } from '../../mock_data'; jest.mock('~/static_site_editor/services/load_source_content', () => jest.fn()); diff --git a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js index 515b5394594..750b777cf5d 100644 --- a/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js +++ b/spec/frontend/static_site_editor/graphql/resolvers/submit_content_changes_spec.js @@ -6,7 +6,7 @@ import { projectId as project, sourcePath, username, - sourceContent as content, + sourceContentYAML as content, savedContentMeta, } from '../../mock_data'; diff --git a/spec/frontend/static_site_editor/mock_data.js b/spec/frontend/static_site_editor/mock_data.js index 96de9b73af0..d861f6c9cd7 100644 --- a/spec/frontend/static_site_editor/mock_data.js +++ b/spec/frontend/static_site_editor/mock_data.js @@ -1,19 +1,22 @@ -export const sourceContentHeader = `--- +export const sourceContentHeaderYAML = `--- layout: handbook-page-toc title: Handbook -twitter_image: '/images/tweets/handbook-gitlab.png' +twitter_image: /images/tweets/handbook-gitlab.png ---`; -export const sourceContentSpacing = ` -`; +export const sourceContentHeaderObjYAML = { + layout: 'handbook-page-toc', + title: 'Handbook', + twitter_image: '/images/tweets/handbook-gitlab.png', +}; +export const sourceContentSpacing = `\n`; export const sourceContentBody = `## On this page {:.no_toc .hidden-md .hidden-lg} - TOC {:toc .hidden-md .hidden-lg} -![image](path/to/image1.png) -`; -export const sourceContent = `${sourceContentHeader}${sourceContentSpacing}${sourceContentBody}`; +![image](path/to/image1.png)`; +export const sourceContentYAML = `${sourceContentHeaderYAML}${sourceContentSpacing}${sourceContentBody}`; export const sourceContentTitle = 'Handbook'; export const username = 'gitlabuser'; diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index c5473596df8..41f8a1075c0 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -13,7 +13,7 @@ import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constant import { projectId as project, returnUrl, - sourceContent as content, + sourceContentYAML as content, sourceContentTitle as title, sourcePath, username, diff --git a/spec/frontend/static_site_editor/services/formatter_spec.js b/spec/frontend/static_site_editor/services/formatter_spec.js index b7600798db9..9e9c4bbd171 100644 --- a/spec/frontend/static_site_editor/services/formatter_spec.js +++ b/spec/frontend/static_site_editor/services/formatter_spec.js @@ -1,6 +1,6 @@ import formatter from '~/static_site_editor/services/formatter'; -describe('formatter', () => { +describe('static_site_editor/services/formatter', () => { const source = `Some text <br> @@ -23,4 +23,17 @@ And even more text`; it('removes extraneous <br> tags', () => { expect(formatter(source)).toMatch(sourceWithoutBrTags); }); + + describe('ordered lists with incorrect content indentation', () => { + it.each` + input | result + ${'12. ordered list item\n13.Next ordered list item'} | ${'12. ordered list item\n13.Next ordered list item'} + ${'12. ordered list item\n - Next ordered list item'} | ${'12. ordered list item\n - Next ordered list item'} + ${'12. ordered list item\n - Next ordered list item'} | ${'12. ordered list item\n - Next ordered list item'} + ${'12. ordered list item\n Next ordered list item'} | ${'12. ordered list item\n Next ordered list item'} + ${'1. ordered list item\n Next ordered list item'} | ${'1. ordered list item\n Next ordered list item'} + `('\ntransforms\n$input \nto\n$result', ({ input, result }) => { + expect(formatter(input)).toBe(result); + }); + }); }); diff --git a/spec/frontend/static_site_editor/services/load_source_content_spec.js b/spec/frontend/static_site_editor/services/load_source_content_spec.js index 87893bb7a6e..54061b7a503 100644 --- a/spec/frontend/static_site_editor/services/load_source_content_spec.js +++ b/spec/frontend/static_site_editor/services/load_source_content_spec.js @@ -2,7 +2,12 @@ import Api from '~/api'; import loadSourceContent from '~/static_site_editor/services/load_source_content'; -import { sourceContent, sourceContentTitle, projectId, sourcePath } from '../mock_data'; +import { + sourceContentYAML as sourceContent, + sourceContentTitle, + projectId, + sourcePath, +} from '../mock_data'; describe('loadSourceContent', () => { describe('requesting source content succeeds', () => { diff --git a/spec/frontend/static_site_editor/services/parse_source_file_spec.js b/spec/frontend/static_site_editor/services/parse_source_file_spec.js index 4588548e614..ab9e63f4cd2 100644 --- a/spec/frontend/static_site_editor/services/parse_source_file_spec.js +++ b/spec/frontend/static_site_editor/services/parse_source_file_spec.js @@ -1,14 +1,29 @@ -import { sourceContent as content, sourceContentBody as body } from '../mock_data'; +import { + sourceContentYAML as content, + sourceContentHeaderYAML as yamlFrontMatter, + sourceContentHeaderObjYAML as yamlFrontMatterObj, + sourceContentBody as body, +} from '../mock_data'; import parseSourceFile from '~/static_site_editor/services/parse_source_file'; -describe('parseSourceFile', () => { +describe('static_site_editor/services/parse_source_file', () => { const contentComplex = [content, content, content].join(''); const complexBody = [body, content, content].join(''); const edit = 'and more'; const newContent = `${content} ${edit}`; const newContentComplex = `${contentComplex} ${edit}`; + describe('unmodified front matter', () => { + it.each` + parsedSource + ${parseSourceFile(content)} + ${parseSourceFile(contentComplex)} + `('returns $targetFrontMatter when frontMatter queried', ({ parsedSource }) => { + expect(parsedSource.matter()).toEqual(yamlFrontMatterObj); + }); + }); + describe('unmodified content', () => { it.each` parsedSource @@ -34,21 +49,50 @@ describe('parseSourceFile', () => { ); }); + describe('modified front matter', () => { + const newYamlFrontMatter = '---\nnewKey: newVal\n---'; + const newYamlFrontMatterObj = { newKey: 'newVal' }; + const contentWithNewFrontMatter = content.replace(yamlFrontMatter, newYamlFrontMatter); + const contentComplexWithNewFrontMatter = contentComplex.replace( + yamlFrontMatter, + newYamlFrontMatter, + ); + + it.each` + parsedSource | targetContent + ${parseSourceFile(content)} | ${contentWithNewFrontMatter} + ${parseSourceFile(contentComplex)} | ${contentComplexWithNewFrontMatter} + `( + 'returns the correct front matter and modified content', + ({ parsedSource, targetContent }) => { + expect(parsedSource.matter()).toMatchObject(yamlFrontMatterObj); + + parsedSource.syncMatter(newYamlFrontMatterObj); + + expect(parsedSource.matter()).toMatchObject(newYamlFrontMatterObj); + expect(parsedSource.content()).toBe(targetContent); + }, + ); + }); + describe('modified content', () => { const newBody = `${body} ${edit}`; const newComplexBody = `${complexBody} ${edit}`; it.each` - parsedSource | isModified | targetRaw | targetBody - ${parseSourceFile(content)} | ${false} | ${content} | ${body} - ${parseSourceFile(content)} | ${true} | ${newContent} | ${newBody} - ${parseSourceFile(contentComplex)} | ${false} | ${contentComplex} | ${complexBody} - ${parseSourceFile(contentComplex)} | ${true} | ${newContentComplex} | ${newComplexBody} + parsedSource | hasMatter | isModified | targetRaw | targetBody + ${parseSourceFile(content)} | ${true} | ${false} | ${content} | ${body} + ${parseSourceFile(content)} | ${true} | ${true} | ${newContent} | ${newBody} + ${parseSourceFile(contentComplex)} | ${true} | ${false} | ${contentComplex} | ${complexBody} + ${parseSourceFile(contentComplex)} | ${true} | ${true} | ${newContentComplex} | ${newComplexBody} + ${parseSourceFile(body)} | ${false} | ${false} | ${body} | ${body} + ${parseSourceFile(body)} | ${false} | ${true} | ${newBody} | ${newBody} `( 'returns $isModified after a $targetRaw sync', - ({ parsedSource, isModified, targetRaw, targetBody }) => { - parsedSource.sync(targetRaw); + ({ parsedSource, hasMatter, isModified, targetRaw, targetBody }) => { + parsedSource.syncContent(targetRaw); + expect(parsedSource.hasMatter()).toBe(hasMatter); expect(parsedSource.isModified()).toBe(isModified); expect(parsedSource.content()).toBe(targetRaw); expect(parsedSource.content(true)).toBe(targetBody); diff --git a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js index 645ccedf7e7..d464e6b1895 100644 --- a/spec/frontend/static_site_editor/services/submit_content_changes_spec.js +++ b/spec/frontend/static_site_editor/services/submit_content_changes_spec.js @@ -20,7 +20,7 @@ import { commitMultipleResponse, createMergeRequestResponse, sourcePath, - sourceContent as content, + sourceContentYAML as content, trackingCategory, images, } from '../mock_data'; diff --git a/spec/frontend/tooltips/components/tooltips_spec.js b/spec/frontend/tooltips/components/tooltips_spec.js new file mode 100644 index 00000000000..0edc5248629 --- /dev/null +++ b/spec/frontend/tooltips/components/tooltips_spec.js @@ -0,0 +1,202 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlTooltip } from '@gitlab/ui'; +import { useMockMutationObserver } from 'helpers/mock_dom_observer'; +import Tooltips from '~/tooltips/components/tooltips.vue'; + +describe('tooltips/components/tooltips.vue', () => { + const { trigger: triggerMutate, observersCount } = useMockMutationObserver(); + let wrapper; + + const buildWrapper = () => { + wrapper = shallowMount(Tooltips); + }; + + const createTooltipTarget = (attributes = {}) => { + const target = document.createElement('button'); + const defaults = { + title: 'default title', + ...attributes, + }; + + Object.keys(defaults).forEach(name => { + target.setAttribute(name, defaults[name]); + }); + + document.body.appendChild(target); + + return target; + }; + + const allTooltips = () => wrapper.findAll(GlTooltip); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('addTooltips', () => { + let target; + + beforeEach(() => { + buildWrapper(); + + target = createTooltipTarget(); + }); + + it('attaches tooltips to the targets specified', async () => { + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTooltip).props('target')).toBe(target); + }); + + it('does not attach a tooltip twice to the same element', async () => { + wrapper.vm.addTooltips([target]); + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.findAll(GlTooltip)).toHaveLength(1); + }); + + it('sets tooltip content from title attribute', async () => { + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTooltip).text()).toBe(target.getAttribute('title')); + }); + + it('supports HTML content', async () => { + target = createTooltipTarget({ + title: 'content with <b>HTML</b>', + 'data-html': true, + }); + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTooltip).html()).toContain(target.getAttribute('title')); + }); + + it.each` + attribute | value | prop + ${'data-placement'} | ${'bottom'} | ${'placement'} + ${'data-container'} | ${'custom-container'} | ${'container'} + ${'data-boundary'} | ${'viewport'} | ${'boundary'} + ${'data-triggers'} | ${'manual'} | ${'triggers'} + `( + 'sets $prop to $value when $attribute is set in target', + async ({ attribute, value, prop }) => { + target = createTooltipTarget({ [attribute]: value }); + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTooltip).props(prop)).toBe(value); + }, + ); + }); + + describe('dispose', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('removes all tooltips when elements is nil', async () => { + wrapper.vm.addTooltips([createTooltipTarget(), createTooltipTarget()]); + await wrapper.vm.$nextTick(); + + wrapper.vm.dispose(); + await wrapper.vm.$nextTick(); + + expect(allTooltips()).toHaveLength(0); + }); + + it('removes the tooltips that target the elements specified', async () => { + const target = createTooltipTarget(); + + wrapper.vm.addTooltips([target, createTooltipTarget()]); + await wrapper.vm.$nextTick(); + + wrapper.vm.dispose(target); + await wrapper.vm.$nextTick(); + + expect(allTooltips()).toHaveLength(1); + }); + }); + + describe('observe', () => { + beforeEach(() => { + buildWrapper(); + }); + + it('removes tooltip when target is removed from the document', async () => { + const target = createTooltipTarget(); + + wrapper.vm.addTooltips([target, createTooltipTarget()]); + await wrapper.vm.$nextTick(); + + triggerMutate(document.body, { + entry: { removedNodes: [target] }, + options: { childList: true }, + }); + await wrapper.vm.$nextTick(); + + expect(allTooltips()).toHaveLength(1); + }); + }); + + describe('triggerEvent', () => { + it('triggers a bootstrap-vue tooltip global event for the tooltip specified', async () => { + const target = createTooltipTarget(); + const event = 'hide'; + + buildWrapper(); + + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + wrapper.vm.triggerEvent(target, event); + + expect(wrapper.find(GlTooltip).emitted(event)).toHaveLength(1); + }); + }); + + describe('fixTitle', () => { + it('updates tooltip content with the latest value the target title property', async () => { + const target = createTooltipTarget(); + const currentTitle = 'title'; + const newTitle = 'new title'; + + target.setAttribute('title', currentTitle); + + buildWrapper(); + + wrapper.vm.addTooltips([target]); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTooltip).text()).toBe(currentTitle); + + target.setAttribute('title', newTitle); + wrapper.vm.fixTitle(target); + + await wrapper.vm.$nextTick(); + + expect(wrapper.find(GlTooltip).text()).toBe(newTitle); + }); + }); + + it('disconnects mutation observer on beforeDestroy', () => { + buildWrapper(); + wrapper.vm.addTooltips([createTooltipTarget()]); + + expect(observersCount()).toBe(1); + + wrapper.destroy(); + expect(observersCount()).toBe(0); + }); +}); diff --git a/spec/frontend/tooltips/index_spec.js b/spec/frontend/tooltips/index_spec.js new file mode 100644 index 00000000000..cc72adee57d --- /dev/null +++ b/spec/frontend/tooltips/index_spec.js @@ -0,0 +1,149 @@ +import jQuery from 'jquery'; +import { initTooltips, dispose, destroy, hide, show, enable, disable, fixTitle } from '~/tooltips'; + +describe('tooltips/index.js', () => { + let tooltipsApp; + + const createTooltipTarget = () => { + const target = document.createElement('button'); + const attributes = { + title: 'default title', + }; + + Object.keys(attributes).forEach(name => { + target.setAttribute(name, attributes[name]); + }); + + target.classList.add('has-tooltip'); + + document.body.appendChild(target); + + return target; + }; + + const buildTooltipsApp = () => { + tooltipsApp = initTooltips({ selector: '.has-tooltip' }); + }; + + const triggerEvent = (target, eventName = 'mouseenter') => { + const event = new Event(eventName); + + target.dispatchEvent(event); + }; + + beforeEach(() => { + window.gon.glTooltipsEnabled = true; + }); + + afterEach(() => { + document.body.childNodes.forEach(node => node.remove()); + destroy(); + }); + + describe('initTooltip', () => { + it('attaches a GlTooltip for the elements specified in the selector', async () => { + const target = createTooltipTarget(); + + buildTooltipsApp(); + + triggerEvent(target); + + await tooltipsApp.$nextTick(); + + expect(document.querySelector('.gl-tooltip')).not.toBe(null); + expect(document.querySelector('.gl-tooltip').innerHTML).toContain('default title'); + }); + + it('supports triggering a tooltip in custom events', async () => { + const target = createTooltipTarget(); + + buildTooltipsApp(); + triggerEvent(target, 'click'); + + await tooltipsApp.$nextTick(); + + expect(document.querySelector('.gl-tooltip')).not.toBe(null); + expect(document.querySelector('.gl-tooltip').innerHTML).toContain('default title'); + }); + }); + + describe('dispose', () => { + it('removes tooltips that target the elements specified', async () => { + const target = createTooltipTarget(); + + buildTooltipsApp(); + triggerEvent(target); + + await tooltipsApp.$nextTick(); + + expect(document.querySelector('.gl-tooltip')).not.toBe(null); + + dispose([target]); + + await tooltipsApp.$nextTick(); + + expect(document.querySelector('.gl-tooltip')).toBe(null); + }); + }); + + it.each` + methodName | method | event + ${'enable'} | ${enable} | ${'enable'} + ${'disable'} | ${disable} | ${'disable'} + ${'hide'} | ${hide} | ${'close'} + ${'show'} | ${show} | ${'open'} + `( + '$methodName calls triggerEvent in tooltip app with $event event', + async ({ method, event }) => { + const target = createTooltipTarget(); + + buildTooltipsApp(); + + await tooltipsApp.$nextTick(); + + jest.spyOn(tooltipsApp, 'triggerEvent'); + + method([target]); + + expect(tooltipsApp.triggerEvent).toHaveBeenCalledWith(target, event); + }, + ); + + it('fixTitle calls fixTitle in tooltip app with the target specified', async () => { + const target = createTooltipTarget(); + + buildTooltipsApp(); + + await tooltipsApp.$nextTick(); + + jest.spyOn(tooltipsApp, 'fixTitle'); + + fixTitle([target]); + + expect(tooltipsApp.fixTitle).toHaveBeenCalledWith(target); + }); + + describe('when glTooltipsEnabled feature flag is disabled', () => { + beforeEach(() => { + window.gon.glTooltipsEnabled = false; + }); + + it.each` + method | methodName | bootstrapParams + ${dispose} | ${'dispose'} | ${'dispose'} + ${fixTitle} | ${'fixTitle'} | ${'_fixTitle'} + ${enable} | ${'enable'} | ${'enable'} + ${disable} | ${'disable'} | ${'disable'} + ${hide} | ${'hide'} | ${'hide'} + ${show} | ${'show'} | ${'show'} + `('delegates $methodName to bootstrap tooltip API', ({ method, bootstrapParams }) => { + const elements = jQuery(createTooltipTarget()); + + jest.spyOn(jQuery.fn, 'tooltip'); + + method(elements); + + expect(elements.tooltip).toHaveBeenCalledWith(bootstrapParams); + }); + }); +}); diff --git a/spec/frontend/tracking_spec.js b/spec/frontend/tracking_spec.js index 8acfa655c2c..e2d39ffeaf0 100644 --- a/spec/frontend/tracking_spec.js +++ b/spec/frontend/tracking_spec.js @@ -1,5 +1,5 @@ import { setHTMLFixture } from './helpers/fixtures'; -import Tracking, { initUserTracking } from '~/tracking'; +import Tracking, { initUserTracking, initDefaultTrackers } from '~/tracking'; describe('Tracking', () => { let snowplowSpy; @@ -17,11 +17,6 @@ describe('Tracking', () => { }); describe('initUserTracking', () => { - beforeEach(() => { - bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); - trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); - }); - it('calls through to get a new tracker with the expected options', () => { initUserTracking(); expect(snowplowSpy).toHaveBeenCalledWith('newTracker', '_namespace_', 'app.gitfoo.com', { @@ -38,9 +33,16 @@ describe('Tracking', () => { linkClickTracking: false, }); }); + }); + + describe('initDefaultTrackers', () => { + beforeEach(() => { + bindDocumentSpy = jest.spyOn(Tracking, 'bindDocument').mockImplementation(() => null); + trackLoadEventsSpy = jest.spyOn(Tracking, 'trackLoadEvents').mockImplementation(() => null); + }); it('should activate features based on what has been enabled', () => { - initUserTracking(); + initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableActivityTracking', 30, 30); expect(snowplowSpy).toHaveBeenCalledWith('trackPageView'); expect(snowplowSpy).not.toHaveBeenCalledWith('enableFormTracking'); @@ -52,18 +54,18 @@ describe('Tracking', () => { linkClickTracking: true, }; - initUserTracking(); + initDefaultTrackers(); expect(snowplowSpy).toHaveBeenCalledWith('enableFormTracking'); expect(snowplowSpy).toHaveBeenCalledWith('enableLinkClickTracking'); }); it('binds the document event handling', () => { - initUserTracking(); + initDefaultTrackers(); expect(bindDocumentSpy).toHaveBeenCalled(); }); it('tracks page loaded events', () => { - initUserTracking(); + initDefaultTrackers(); expect(trackLoadEventsSpy).toHaveBeenCalled(); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mock_data.js b/spec/frontend/vue_mr_widget/components/mock_data.js index 39c7d75cda5..73e254f2b1a 100644 --- a/spec/frontend/vue_mr_widget/components/mock_data.js +++ b/spec/frontend/vue_mr_widget/components/mock_data.js @@ -1,4 +1,3 @@ -// eslint-disable-next-line import/prefer-default-export export const artifactsList = [ { text: 'result.txt', diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js index 60f970e0018..4e3e918f7fb 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_container_spec.js @@ -20,8 +20,8 @@ describe('MrWidgetContainer', () => { it('has layout', () => { factory(); - expect(wrapper.is('.mr-widget-heading')).toBe(true); - expect(wrapper.contains('.mr-widget-content')).toBe(true); + expect(wrapper.classes()).toContain('mr-widget-heading'); + expect(wrapper.find('.mr-widget-content').exists()).toBe(true); }); it('accepts default slot', () => { @@ -31,7 +31,7 @@ describe('MrWidgetContainer', () => { }, }); - expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true); + expect(wrapper.find('.mr-widget-content .test-body').exists()).toBe(true); }); it('accepts footer slot', () => { @@ -42,7 +42,7 @@ describe('MrWidgetContainer', () => { }, }); - expect(wrapper.contains('.mr-widget-content .test-body')).toBe(true); - expect(wrapper.contains('.test-footer')).toBe(true); + expect(wrapper.find('.mr-widget-content .test-body').exists()).toBe(true); + expect(wrapper.find('.test-footer').exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js index 69a50899d4d..3e111cd308a 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_expandable_section_spec.js @@ -18,7 +18,7 @@ describe('MrWidgetExpanableSection', () => { }); it('renders Icon', () => { - expect(wrapper.contains(GlIcon)).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); }); it('renders header slot', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js index 21058005d29..caea9a757ae 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_header_spec.js @@ -25,10 +25,14 @@ describe('MRWidgetHeader', () => { const downloadEmailPatchesEl = vm.$el.querySelector('.js-download-email-patches'); const downloadPlainDiffEl = vm.$el.querySelector('.js-download-plain-diff'); - expect(downloadEmailPatchesEl.textContent.trim()).toEqual('Email patches'); - expect(downloadEmailPatchesEl.getAttribute('href')).toEqual('/mr/email-patches'); - expect(downloadPlainDiffEl.textContent.trim()).toEqual('Plain diff'); - expect(downloadPlainDiffEl.getAttribute('href')).toEqual('/mr/plainDiffPath'); + expect(downloadEmailPatchesEl.innerText.trim()).toEqual('Email patches'); + expect(downloadEmailPatchesEl.querySelector('a').getAttribute('href')).toEqual( + '/mr/email-patches', + ); + expect(downloadPlainDiffEl.innerText.trim()).toEqual('Plain diff'); + expect(downloadPlainDiffEl.querySelector('a').getAttribute('href')).toEqual( + '/mr/plainDiffPath', + ); }; describe('computed', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js index cee0b9b0118..ea8b33495ab 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_icon_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const TEST_ICON = 'commit'; @@ -21,6 +21,6 @@ describe('MrWidgetIcon', () => { it('renders icon and container', () => { expect(wrapper.is('.circle-icon-container')).toBe(true); - expect(wrapper.find(Icon).props('name')).toEqual(TEST_ICON); + expect(wrapper.find(GlIcon).props('name')).toEqual(TEST_ICON); }); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js index 6486826c3ec..7ecd8629607 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_spec.js @@ -29,6 +29,8 @@ describe('MRWidgetPipeline', () => { const findAllPipelineStages = () => wrapper.findAll(PipelineStage); const findPipelineCoverage = () => wrapper.find('[data-testid="pipeline-coverage"]'); const findPipelineCoverageDelta = () => wrapper.find('[data-testid="pipeline-coverage-delta"]'); + const findPipelineCoverageTooltipText = () => + wrapper.find('[data-testid="pipeline-coverage-tooltip"]').text(); const findMonitoringPipelineMessage = () => wrapper.find('[data-testid="monitoring-pipeline-message"]'); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); @@ -49,257 +51,208 @@ describe('MRWidgetPipeline', () => { } }); - describe('computed', () => { - describe('hasPipeline', () => { - beforeEach(() => { - createWrapper(); - }); - - it('should return true when there is a pipeline', () => { - expect(wrapper.vm.hasPipeline).toBe(true); - }); + it('should render CI error if there is a pipeline, but no status', () => { + createWrapper({ ciStatus: null }, mount); + expect(findCIErrorMessage().text()).toBe(ciErrorMessage); + }); - it('should return false when there is no pipeline', async () => { - wrapper.setProps({ pipeline: {} }); + it('should render a loading state when no pipeline is found', () => { + createWrapper({ pipeline: {} }, mount); - await wrapper.vm.$nextTick(); + expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage); + expect(findLoadingIcon().exists()).toBe(true); + }); - expect(wrapper.vm.hasPipeline).toBe(false); + describe('with a pipeline', () => { + beforeEach(() => { + createWrapper({ + pipelineCoverageDelta: mockData.pipelineCoverageDelta, + buildsWithCoverage: mockData.buildsWithCoverage, }); }); - describe('hasCIError', () => { - beforeEach(() => { - createWrapper(); - }); + it('should render pipeline ID', () => { + expect( + findPipelineID() + .text() + .trim(), + ).toBe(`#${mockData.pipeline.id}`); + }); - it('should return false when there is no CI error', () => { - expect(wrapper.vm.hasCIError).toBe(false); - }); + it('should render pipeline status and commit id', () => { + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); - it('should return true when there is a pipeline, but no ci status', async () => { - wrapper.setProps({ ciStatus: null }); + expect( + findCommitLink() + .text() + .trim(), + ).toBe(mockData.pipeline.commit.short_id); - await wrapper.vm.$nextTick(); + expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path); + }); - expect(wrapper.vm.hasCIError).toBe(true); - }); + it('should render pipeline graph', () => { + expect(findPipelineGraph().exists()).toBe(true); + expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); }); - describe('coverageDeltaClass', () => { - beforeEach(() => { - createWrapper({ pipelineCoverageDelta: '0' }); + describe('should render pipeline coverage information', () => { + it('should render coverage percentage', () => { + expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); }); - it('should return no class if there is no coverage change', async () => { - expect(wrapper.vm.coverageDeltaClass).toBe(''); + it('should render coverage delta', () => { + expect(findPipelineCoverageDelta().exists()).toBe(true); + expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`); }); - it('should return text-success if the coverage increased', async () => { - wrapper.setProps({ pipelineCoverageDelta: '10' }); - - await wrapper.vm.$nextTick(); + it('coverage delta should have no special style if there is no coverage change', () => { + createWrapper({ pipelineCoverageDelta: '0' }); + expect(findPipelineCoverageDelta().classes()).toEqual([]); + }); - expect(wrapper.vm.coverageDeltaClass).toBe('text-success'); + it('coverage delta should have text-success style if coverage increased', () => { + createWrapper({ pipelineCoverageDelta: '10' }); + expect(findPipelineCoverageDelta().classes()).toEqual(['text-success']); }); - it('should return text-danger if the coverage decreased', async () => { - wrapper.setProps({ pipelineCoverageDelta: '-12' }); + it('coverage delta should have text-danger style if coverage increased', () => { + createWrapper({ pipelineCoverageDelta: '-10' }); + expect(findPipelineCoverageDelta().classes()).toEqual(['text-danger']); + }); - await wrapper.vm.$nextTick(); + it('should render tooltip for jobs contributing to code coverage', () => { + const tooltipText = findPipelineCoverageTooltipText(); + const expectedDescription = `Coverage value for this pipeline was calculated by averaging the resulting coverage values of ${mockData.buildsWithCoverage.length} jobs.`; - expect(wrapper.vm.coverageDeltaClass).toBe('text-danger'); + expect(tooltipText).toContain(expectedDescription); }); + + it.each(mockData.buildsWithCoverage)( + 'should have name and coverage for build %s listed in tooltip', + build => { + const tooltipText = findPipelineCoverageTooltipText(); + + expect(tooltipText).toContain(`${build.name} (${build.coverage}%)`); + }, + ); }); }); - describe('rendered output', () => { + describe('without commit path', () => { beforeEach(() => { - createWrapper({ ciStatus: null }, mount); - }); + const mockCopy = JSON.parse(JSON.stringify(mockData)); + delete mockCopy.pipeline.commit; - it('should render CI error if there is a pipeline, but no status', async () => { - expect(findCIErrorMessage().text()).toBe(ciErrorMessage); + createWrapper({}); }); - it('should render a loading state when no pipeline is found', async () => { - wrapper.setProps({ - pipeline: {}, - hasCi: false, - pipelineMustSucceed: true, - }); - - await wrapper.vm.$nextTick(); - - expect(findMonitoringPipelineMessage().text()).toBe(monitoringMessage); - expect(findLoadingIcon().exists()).toBe(true); + it('should render pipeline ID', () => { + expect( + findPipelineID() + .text() + .trim(), + ).toBe(`#${mockData.pipeline.id}`); }); - describe('with a pipeline', () => { - beforeEach(() => { - createWrapper( - { - pipelineCoverageDelta: mockData.pipelineCoverageDelta, - }, - mount, - ); - }); - - it('should render pipeline ID', () => { - expect( - findPipelineID() - .text() - .trim(), - ).toBe(`#${mockData.pipeline.id}`); - }); - - it('should render pipeline status and commit id', () => { - expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); - - expect( - findCommitLink() - .text() - .trim(), - ).toBe(mockData.pipeline.commit.short_id); - - expect(findCommitLink().attributes('href')).toBe(mockData.pipeline.commit.commit_path); - }); - - it('should render pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(true); - expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); - }); - - it('should render coverage information', () => { - expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); - }); + it('should render pipeline status', () => { + expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); + }); - it('should render pipeline coverage delta information', () => { - expect(findPipelineCoverageDelta().exists()).toBe(true); - expect(findPipelineCoverageDelta().text()).toBe(`(${mockData.pipelineCoverageDelta}%)`); - }); + it('should render pipeline graph', () => { + expect(findPipelineGraph().exists()).toBe(true); + expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); }); - describe('without commit path', () => { - beforeEach(() => { - const mockCopy = JSON.parse(JSON.stringify(mockData)); - delete mockCopy.pipeline.commit; + it('should render coverage information', () => { + expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); + }); + }); - createWrapper({}, mount); - }); + describe('without coverage', () => { + beforeEach(() => { + const mockCopy = JSON.parse(JSON.stringify(mockData)); + delete mockCopy.pipeline.coverage; - it('should render pipeline ID', () => { - expect( - findPipelineID() - .text() - .trim(), - ).toBe(`#${mockData.pipeline.id}`); - }); + createWrapper({ pipeline: mockCopy.pipeline }); + }); - it('should render pipeline status', () => { - expect(findPipelineInfoContainer().text()).toMatch(mockData.pipeline.details.status.label); - }); + it('should not render a coverage component', () => { + expect(findPipelineCoverage().exists()).toBe(false); + }); + }); - it('should render pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(true); - expect(findAllPipelineStages().length).toBe(mockData.pipeline.details.stages.length); - }); + describe('without a pipeline graph', () => { + beforeEach(() => { + const mockCopy = JSON.parse(JSON.stringify(mockData)); + delete mockCopy.pipeline.details.stages; - it('should render coverage information', () => { - expect(findPipelineCoverage().text()).toMatch(`Coverage ${mockData.pipeline.coverage}%`); + createWrapper({ + pipeline: mockCopy.pipeline, }); }); - describe('without coverage', () => { - beforeEach(() => { - const mockCopy = JSON.parse(JSON.stringify(mockData)); - delete mockCopy.pipeline.coverage; - - createWrapper( - { - pipeline: mockCopy.pipeline, - }, - mount, - ); - }); - - it('should not render a coverage component', () => { - expect(findPipelineCoverage().exists()).toBe(false); - }); + it('should not render a pipeline graph', () => { + expect(findPipelineGraph().exists()).toBe(false); }); + }); - describe('without a pipeline graph', () => { - beforeEach(() => { - const mockCopy = JSON.parse(JSON.stringify(mockData)); - delete mockCopy.pipeline.details.stages; + describe('for each type of pipeline', () => { + let pipeline; - createWrapper({ - pipeline: mockCopy.pipeline, - }); - }); + beforeEach(() => { + ({ pipeline } = JSON.parse(JSON.stringify(mockData))); - it('should not render a pipeline graph', () => { - expect(findPipelineGraph().exists()).toBe(false); - }); + pipeline.details.name = 'Pipeline'; + pipeline.merge_request_event_type = undefined; + pipeline.ref.tag = false; + pipeline.ref.branch = false; }); - describe('for each type of pipeline', () => { - let pipeline; - - beforeEach(() => { - ({ pipeline } = JSON.parse(JSON.stringify(mockData))); - - pipeline.details.name = 'Pipeline'; - pipeline.merge_request_event_type = undefined; - pipeline.ref.tag = false; - pipeline.ref.branch = false; + const factory = () => { + createWrapper({ + pipeline, + sourceBranchLink: mockData.source_branch_link, }); + }; - const factory = () => { - createWrapper({ - pipeline, - sourceBranchLink: mockData.source_branch_link, - }); - }; - - describe('for a branch pipeline', () => { - it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => { - pipeline.ref.branch = true; + describe('for a branch pipeline', () => { + it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => { + pipeline.ref.branch = true; - factory(); + factory(); - const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`; - const actual = trimText(findPipelineInfoContainer().text()); + const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id} on ${mockData.source_branch_link}`; + const actual = trimText(findPipelineInfoContainer().text()); - expect(actual).toBe(expected); - }); + expect(actual).toBe(expected); }); + }); - describe('for a tag pipeline', () => { - it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => { - pipeline.ref.tag = true; + describe('for a tag pipeline', () => { + it('renders a pipeline widget that reads "Pipeline <ID> <status> for <SHA> on <branch>"', () => { + pipeline.ref.tag = true; - factory(); + factory(); - const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; - const actual = trimText(findPipelineInfoContainer().text()); + const expected = `Pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; + const actual = trimText(findPipelineInfoContainer().text()); - expect(actual).toBe(expected); - }); + expect(actual).toBe(expected); }); + }); - describe('for a detached merge request pipeline', () => { - it('renders a pipeline widget that reads "Detached merge request pipeline <ID> <status> for <SHA>"', () => { - pipeline.details.name = 'Detached merge request pipeline'; - pipeline.merge_request_event_type = 'detached'; + describe('for a detached merge request pipeline', () => { + it('renders a pipeline widget that reads "Detached merge request pipeline <ID> <status> for <SHA>"', () => { + pipeline.details.name = 'Detached merge request pipeline'; + pipeline.merge_request_event_type = 'detached'; - factory(); + factory(); - const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; - const actual = trimText(findPipelineInfoContainer().text()); + const expected = `Detached merge request pipeline #${pipeline.id} ${pipeline.details.status.label} for ${pipeline.commit.short_id}`; + const actual = trimText(findPipelineInfoContainer().text()); - expect(actual).toBe(expected); - }); + expect(actual).toBe(expected); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js index 7b063653a93..7d47621c64a 100644 --- a/spec/frontend/vue_mr_widget/components/review_app_link_spec.js +++ b/spec/frontend/vue_mr_widget/components/review_app_link_spec.js @@ -30,7 +30,7 @@ describe('review app link', () => { }); it('renders provided cssClass as class attribute', () => { - expect(el.getAttribute('class')).toEqual(props.cssClass); + expect(el.getAttribute('class')).toContain(props.cssClass); }); it('renders View app text', () => { diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js index 67746b062b9..62fc3330444 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commits_header_spec.js @@ -1,6 +1,5 @@ import { shallowMount } from '@vue/test-utils'; import CommitsHeader from '~/vue_merge_request_widget/components/states/commits_header.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('Commits header component', () => { let wrapper; @@ -23,7 +22,6 @@ describe('Commits header component', () => { const findHeaderWrapper = () => wrapper.find('.js-mr-widget-commits-count'); const findCommitToggle = () => wrapper.find('.commit-edit-toggle'); - const findIcon = () => wrapper.find(Icon); const findCommitsCountMessage = () => wrapper.find('.commits-count-message'); const findTargetBranchMessage = () => wrapper.find('.label-branch'); const findModifyButton = () => wrapper.find('.modify-message-button'); @@ -61,7 +59,7 @@ describe('Commits header component', () => { wrapper.setData({ expanded: false }); return wrapper.vm.$nextTick().then(() => { - expect(findIcon().props('name')).toBe('chevron-right'); + expect(findCommitToggle().props('icon')).toBe('chevron-right'); }); }); @@ -119,7 +117,7 @@ describe('Commits header component', () => { it('has a chevron-down icon', done => { wrapper.vm.$nextTick(() => { - expect(findIcon().props('name')).toBe('chevron-down'); + expect(findCommitToggle().props('icon')).toBe('chevron-down'); done(); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js index c3a16a776a7..19f8a67d066 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_conflicts_spec.js @@ -148,8 +148,8 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.contains('.js-resolve-conflicts-button')).toBe(false); - expect(vm.contains('.js-merge-locally-button')).toBe(false); + expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); + expect(vm.find('.js-merge-locally-button').exists()).toBe(false); }); it('should not have resolve button when no conflict resolution path', () => { @@ -161,7 +161,7 @@ describe('MRWidgetConflicts', () => { }, }); - expect(vm.contains('.js-resolve-conflicts-button')).toBe(false); + expect(vm.find('.js-resolve-conflicts-button').exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js index f591393d721..6778a8f4a1f 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_failed_to_merge_spec.js @@ -125,7 +125,11 @@ describe('MRWidgetFailedToMerge', () => { }); it('renders refresh button', () => { - expect(vm.$el.querySelector('.js-refresh-button').textContent.trim()).toEqual('Refresh now'); + expect( + vm.$el + .querySelector('[data-testid="merge-request-failed-refresh-button"]') + .textContent.trim(), + ).toEqual('Refresh now'); }); it('renders remaining time', () => { diff --git a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js index ffcf9b1477a..7fe6b44ecc7 100644 --- a/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/terraform/mr_widget_terraform_container_spec.js @@ -1,4 +1,4 @@ -import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; @@ -49,8 +49,8 @@ describe('MrWidgetTerraformConainer', () => { }); it('diplays loading skeleton', () => { - expect(wrapper.contains(GlSkeletonLoading)).toBe(true); - expect(wrapper.contains(MrWidgetExpanableSection)).toBe(false); + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(true); + expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(false); }); }); @@ -61,8 +61,8 @@ describe('MrWidgetTerraformConainer', () => { }); it('displays terraform content', () => { - expect(wrapper.contains(GlSkeletonLoading)).toBe(false); - expect(wrapper.contains(MrWidgetExpanableSection)).toBe(true); + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); + expect(wrapper.find(MrWidgetExpanableSection).exists()).toBe(true); expect(findPlans()).toEqual(Object.values(plans)); }); @@ -156,7 +156,7 @@ describe('MrWidgetTerraformConainer', () => { }); it('stops loading', () => { - expect(wrapper.contains(GlSkeletonLoading)).toBe(false); + expect(wrapper.find(GlSkeletonLoading).exists()).toBe(false); }); it('generates one broken plan', () => { diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js index 6adf4975414..bc0d2501809 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_action_button_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlIcon, GlLoadingIcon, GlDeprecatedButton } from '@gitlab/ui'; +import { GlIcon, GlLoadingIcon, GlButton } from '@gitlab/ui'; import DeploymentActionButton from '~/vue_merge_request_widget/components/deployment/deployment_action_button.vue'; import { CREATED, @@ -13,6 +13,7 @@ const baseProps = { actionsConfiguration: actionButtonMocks[DEPLOYING], actionInProgress: null, computedDeploymentStatus: CREATED, + icon: 'play', }; describe('Deployment action button', () => { @@ -28,18 +29,18 @@ describe('Deployment action button', () => { wrapper.destroy(); }); - describe('when passed only icon', () => { + describe('when passed only icon via props', () => { beforeEach(() => { factory({ propsData: baseProps, - slots: { default: ['<gl-icon name="stop" />'] }, + slots: {}, stubs: { 'gl-icon': GlIcon, }, }); }); - it('renders slot correctly', () => { + it('renders prop icon correctly', () => { expect(wrapper.find(GlIcon).exists()).toBe(true); }); }); @@ -49,7 +50,7 @@ describe('Deployment action button', () => { factory({ propsData: baseProps, slots: { - default: ['<gl-icon name="play" />', `<span>${actionButtonMocks[DEPLOYING]}</span>`], + default: [`<span>${actionButtonMocks[DEPLOYING]}</span>`], }, stubs: { 'gl-icon': GlIcon, @@ -57,7 +58,7 @@ describe('Deployment action button', () => { }); }); - it('renders slot correctly', () => { + it('renders slot and icon prop correctly', () => { expect(wrapper.find(GlIcon).exists()).toBe(true); expect(wrapper.text()).toContain(actionButtonMocks[DEPLOYING]); }); @@ -75,7 +76,7 @@ describe('Deployment action button', () => { it('is disabled and shows the loading icon', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); - expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(true); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); }); }); @@ -90,7 +91,7 @@ describe('Deployment action button', () => { }); it('is disabled and does not show the loading icon', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(true); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); }); }); @@ -106,7 +107,7 @@ describe('Deployment action button', () => { }); it('is disabled and does not show the loading icon', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(true); + expect(wrapper.find(GlButton).props('disabled')).toBe(true); }); }); @@ -118,7 +119,7 @@ describe('Deployment action button', () => { }); it('is not disabled nor does it show the loading icon', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - expect(wrapper.find(GlDeprecatedButton).props('disabled')).toBe(false); + expect(wrapper.find(GlButton).props('disabled')).toBe(false); }); }); }); diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index d64a7f88b6b..4688af30269 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -193,6 +193,7 @@ export default { updated_at: '2017-04-07T15:28:44.800Z', }, pipelineCoverageDelta: '15.25', + buildsWithCoverage: [{ name: 'karma', coverage: '40.2' }, { name: 'rspec', coverage: '80.4' }], work_in_progress: false, source_branch_exists: false, mergeable_discussions_state: true, diff --git a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js index 0bbe040d031..a2ade44b7c4 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -530,7 +530,7 @@ describe('mrWidgetOptions', () => { vm.mr.state = 'readyToMerge'; vm.$nextTick(() => { - const tooltip = vm.$el.querySelector('.fa-question-circle'); + const tooltip = vm.$el.querySelector('[data-testid="question-o-icon"]'); expect(vm.$el.textContent).toContain('Deletes source branch'); expect(tooltip.getAttribute('data-original-title')).toBe( diff --git a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js index 128e0f39c41..631d4647b17 100644 --- a/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js +++ b/spec/frontend/vue_mr_widget/stores/get_state_key_spec.js @@ -30,6 +30,7 @@ describe('getStateKey', () => { expect(bound()).toEqual('notAllowedToMerge'); context.autoMergeEnabled = true; + context.hasMergeableDiscussionsState = true; expect(bound()).toEqual('autoMergeEnabled'); @@ -44,6 +45,7 @@ describe('getStateKey', () => { expect(bound()).toEqual('pipelineBlocked'); context.hasMergeableDiscussionsState = true; + context.autoMergeEnabled = false; expect(bound()).toEqual('unresolvedDiscussions'); diff --git a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js index 48326eda404..b691a366a0f 100644 --- a/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js +++ b/spec/frontend/vue_mr_widget/stores/mr_widget_store_spec.js @@ -69,6 +69,38 @@ describe('MergeRequestStore', () => { }); }); + describe('isPipelineBlocked', () => { + const pipelineWaitingForManualAction = { + details: { + status: { + group: 'manual', + }, + }, + }; + + it('should be `false` when the pipeline status is missing', () => { + store.setData({ ...mockData, pipeline: undefined }); + + expect(store.isPipelineBlocked).toBe(false); + }); + + it('should be `false` when the pipeline is waiting for manual action', () => { + store.setData({ ...mockData, pipeline: pipelineWaitingForManualAction }); + + expect(store.isPipelineBlocked).toBe(false); + }); + + it('should be `true` when the pipeline is waiting for manual action and the pipeline must succeed', () => { + store.setData({ + ...mockData, + pipeline: pipelineWaitingForManualAction, + only_allow_merge_if_pipeline_succeeds: true, + }); + + expect(store.isPipelineBlocked).toBe(true); + }); + }); + describe('isNothingToMergeState', () => { it('returns true when nothingToMerge', () => { store.state = stateKey.nothingToMerge; 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 e84eb7789d3..dfd114a2d1c 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 @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` -<gl-new-dropdown-stub +<gl-dropdown-stub category="primary" headertext="" right="" @@ -12,9 +12,9 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <div class="pb-2 mx-1" > - <gl-new-dropdown-header-stub> + <gl-dropdown-section-header-stub> Clone with SSH - </gl-new-dropdown-header-stub> + </gl-dropdown-section-header-stub> <div class="mx-3" @@ -53,9 +53,9 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` </div> </div> - <gl-new-dropdown-header-stub> + <gl-dropdown-section-header-stub> Clone with HTTP - </gl-new-dropdown-header-stub> + </gl-dropdown-section-header-stub> <div class="mx-3" @@ -94,5 +94,5 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` </div> </div> </div> -</gl-new-dropdown-stub> +</gl-dropdown-stub> `; 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 cd4728baeaa..c2b97f1e7f9 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 @@ -4,7 +4,7 @@ exports[`Expand button on click when short text is provided renders button after <span> <button aria-label="Click to expand text" - class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" style="display: none;" type="button" > @@ -32,7 +32,7 @@ exports[`Expand button on click when short text is provided renders button after <button aria-label="Click to expand text" - class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-append text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" style="" type="button" > @@ -56,7 +56,7 @@ exports[`Expand button when short text is provided renders button before text 1` <span> <button aria-label="Click to expand text" - class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" type="button" > <!----> @@ -83,7 +83,7 @@ exports[`Expand button when short text is provided renders button before text 1` <button aria-label="Click to expand text" - class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-append text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" style="display: none;" type="button" > diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js new file mode 100644 index 00000000000..4dde9d726d1 --- /dev/null +++ b/spec/frontend/vue_shared/components/actions_button_spec.js @@ -0,0 +1,203 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlLink } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ActionsButton from '~/vue_shared/components/actions_button.vue'; + +const TEST_ACTION = { + key: 'action1', + text: 'Sample', + secondaryText: 'Lorem ipsum.', + tooltip: '', + href: '/sample', + attrs: { 'data-test': '123' }, +}; +const TEST_ACTION_2 = { + key: 'action2', + text: 'Sample 2', + secondaryText: 'Dolar sit amit.', + tooltip: 'Dolar sit amit.', + href: '#', + attrs: { 'data-test': '456' }, +}; +const TEST_TOOLTIP = 'Lorem ipsum dolar sit'; + +describe('Actions button component', () => { + let wrapper; + + function createComponent(props) { + wrapper = shallowMount(ActionsButton, { + propsData: { ...props }, + directives: { GlTooltip: createMockDirective() }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const getTooltip = child => { + const directiveBinding = getBinding(child.element, 'gl-tooltip'); + + return directiveBinding.value; + }; + const findLink = () => wrapper.find(GlLink); + const findLinkTooltip = () => getTooltip(findLink()); + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownTooltip = () => getTooltip(findDropdown()); + const parseDropdownItems = () => + findDropdown() + .findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub') + .wrappers.map(x => { + if (x.is('gl-dropdown-divider-stub')) { + return { type: 'divider' }; + } + + const { isCheckItem, isChecked, secondaryText } = x.props(); + + return { + type: 'item', + isCheckItem, + isChecked, + secondaryText, + text: x.text(), + }; + }); + const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt); + const clickLink = (...args) => clickOn(findLink(), ...args); + const clickDropdown = (...args) => clickOn(findDropdown(), ...args); + + describe('with 1 action', () => { + beforeEach(() => { + createComponent({ actions: [TEST_ACTION] }); + }); + + it('should not render dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + + it('should render single button', () => { + const link = findLink(); + + expect(link.attributes()).toEqual({ + class: expect.any(String), + href: TEST_ACTION.href, + ...TEST_ACTION.attrs, + }); + expect(link.text()).toBe(TEST_ACTION.text); + }); + + it('should have tooltip', () => { + expect(findLinkTooltip()).toBe(TEST_ACTION.tooltip); + }); + + it('should have attrs', () => { + expect(findLink().attributes()).toMatchObject(TEST_ACTION.attrs); + }); + + it('can click', () => { + expect(clickLink).not.toThrow(); + }); + }); + + describe('with 1 action with tooltip', () => { + it('should have tooltip', () => { + createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] }); + + expect(findLinkTooltip()).toBe(TEST_TOOLTIP); + }); + }); + + describe('with 1 action with handle', () => { + it('can click and trigger handle', () => { + const handleClick = jest.fn(); + createComponent({ actions: [{ ...TEST_ACTION, handle: handleClick }] }); + + const event = new Event('click'); + clickLink(event); + + expect(handleClick).toHaveBeenCalledWith(event); + }); + }); + + describe('with multiple actions', () => { + let handleAction; + + beforeEach(() => { + handleAction = jest.fn(); + + createComponent({ actions: [{ ...TEST_ACTION, handle: handleAction }, TEST_ACTION_2] }); + }); + + it('should default to selecting first action', () => { + expect(findDropdown().attributes()).toMatchObject({ + text: TEST_ACTION.text, + 'split-href': TEST_ACTION.href, + }); + }); + + it('should handle first action click', () => { + const event = new Event('click'); + + clickDropdown(event); + + expect(handleAction).toHaveBeenCalledWith(event); + }); + + it('should render dropdown items', () => { + expect(parseDropdownItems()).toEqual([ + { + type: 'item', + isCheckItem: true, + isChecked: true, + secondaryText: TEST_ACTION.secondaryText, + text: TEST_ACTION.text, + }, + { type: 'divider' }, + { + type: 'item', + isCheckItem: true, + isChecked: false, + secondaryText: TEST_ACTION_2.secondaryText, + text: TEST_ACTION_2.text, + }, + ]); + }); + + it('should select action 2 when clicked', () => { + expect(wrapper.emitted('select')).toBeUndefined(); + + const action2 = wrapper.find(`[data-testid="action_${TEST_ACTION_2.key}"]`); + action2.vm.$emit('click'); + + expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]); + }); + + it('should have tooltip value', () => { + expect(findDropdownTooltip()).toBe(TEST_ACTION.tooltip); + }); + }); + + describe('with multiple actions and selectedKey', () => { + beforeEach(() => { + createComponent({ actions: [TEST_ACTION, TEST_ACTION_2], selectedKey: TEST_ACTION_2.key }); + }); + + it('should show action 2 as selected', () => { + expect(parseDropdownItems()).toEqual([ + expect.objectContaining({ + type: 'item', + isChecked: false, + }), + { type: 'divider' }, + expect.objectContaining({ + type: 'item', + isChecked: true, + }), + ]); + }); + + it('should have tooltip value', () => { + expect(findDropdownTooltip()).toBe(TEST_ACTION_2.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 new file mode 100644 index 00000000000..9c38ccad8a7 --- /dev/null +++ b/spec/frontend/vue_shared/components/alert_detail_table_spec.js @@ -0,0 +1,74 @@ +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/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index 5cf42ecdc1d..22643a17b2b 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -36,6 +36,6 @@ describe('Blob Rich Viewer component', () => { }); it('is using Markdown View Field', () => { - expect(wrapper.contains(MarkdownFieldView)).toBe(true); + expect(wrapper.find(MarkdownFieldView).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/changed_file_icon_spec.js b/spec/frontend/vue_shared/components/changed_file_icon_spec.js index 03519a6f803..80918c5e771 100644 --- a/spec/frontend/vue_shared/components/changed_file_icon_spec.js +++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const changedFile = () => ({ changed: true }); const stagedFile = () => ({ changed: true, staged: true }); @@ -25,7 +25,7 @@ describe('Changed file icon', () => { wrapper.destroy(); }); - const findIcon = () => wrapper.find(Icon); + const findIcon = () => wrapper.find(GlIcon); const findIconName = () => findIcon().props('name'); const findIconClasses = () => findIcon().classes(); const findTooltipText = () => wrapper.attributes('title'); diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown_spec.js index d9829874b93..5b8576ad761 100644 --- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/clone_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlFormInputGroup, GlNewDropdownHeader } from '@gitlab/ui'; +import { GlFormInputGroup, GlDropdownSectionHeader } from '@gitlab/ui'; import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue'; describe('Clone Dropdown Button', () => { @@ -40,7 +40,7 @@ describe('Clone Dropdown Button', () => { createComponent(); const group = wrapper.findAll(GlFormInputGroup).at(index); expect(group.props('value')).toBe(value); - expect(group.contains(GlFormInputGroup)).toBe(true); + expect(group.find(GlFormInputGroup).exists()).toBe(true); }); it.each` @@ -51,7 +51,7 @@ describe('Clone Dropdown Button', () => { createComponent({ [name]: value }); expect(wrapper.find(GlFormInputGroup).props('value')).toBe(value); - expect(wrapper.findAll(GlNewDropdownHeader).length).toBe(1); + expect(wrapper.findAll(GlDropdownSectionHeader).length).toBe(1); }); }); @@ -63,12 +63,12 @@ describe('Clone Dropdown Button', () => { `('allows null values for the props', ({ name, value }) => { createComponent({ ...defaultPropsData, [name]: value }); - expect(wrapper.findAll(GlNewDropdownHeader).length).toBe(1); + expect(wrapper.findAll(GlDropdownSectionHeader).length).toBe(1); }); it('correctly calculates httpLabel for HTTPS protocol', () => { createComponent({ httpLink: httpsLink }); - expect(wrapper.find(GlNewDropdownHeader).text()).toContain('HTTPS'); + expect(wrapper.find(GlDropdownSectionHeader).text()).toContain('HTTPS'); }); }); }); diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js index 3510c9b699d..9b5c0941a0d 100644 --- a/spec/frontend/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import CommitComponent from '~/vue_shared/components/commit.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; describe('Commit component', () => { @@ -8,7 +8,7 @@ describe('Commit component', () => { let wrapper; const findIcon = name => { - const icons = wrapper.findAll(Icon).filter(c => c.attributes('name') === name); + const icons = wrapper.findAll(GlIcon).filter(c => c.attributes('name') === name); return icons.length ? icons.at(0) : icons; }; @@ -46,7 +46,7 @@ describe('Commit component', () => { expect( wrapper .find('.icon-container') - .find(Icon) + .find(GlIcon) .exists(), ).toBe(true); }); diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index 7bccd6f1a64..5d92af64de0 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import ConfirmModal from '~/vue_shared/components/confirm_modal.vue'; @@ -21,9 +20,14 @@ describe('vue_shared/components/confirm_modal', () => { selector: '.test-button', }; - const actionSpies = { - openModal: jest.fn(), - closeModal: jest.fn(), + const popupMethods = { + hide: jest.fn(), + show: jest.fn(), + }; + + const GlModalStub = { + template: '<div><slot></slot></div>', + methods: popupMethods, }; let wrapper; @@ -34,8 +38,8 @@ describe('vue_shared/components/confirm_modal', () => { ...defaultProps, ...props, }, - methods: { - ...actionSpies, + stubs: { + GlModal: GlModalStub, }, }); }; @@ -44,7 +48,7 @@ describe('vue_shared/components/confirm_modal', () => { wrapper.destroy(); }); - const findModal = () => wrapper.find(GlModal); + const findModal = () => wrapper.find(GlModalStub); const findForm = () => wrapper.find('form'); const findFormData = () => findForm() @@ -103,7 +107,7 @@ describe('vue_shared/components/confirm_modal', () => { }); it('does not close modal', () => { - expect(actionSpies.closeModal).not.toHaveBeenCalled(); + expect(popupMethods.hide).not.toHaveBeenCalled(); }); describe('when modal closed', () => { @@ -112,7 +116,7 @@ describe('vue_shared/components/confirm_modal', () => { }); it('closes modal', () => { - expect(actionSpies.closeModal).toHaveBeenCalled(); + expect(popupMethods.hide).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js index 223e22d650b..afd1f1a3123 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js @@ -234,7 +234,8 @@ describe('DateTimePicker', () => { }); it('unchecks quick range when text is input is clicked', () => { - const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active')); + const findActiveItems = () => + findQuickRangeItems().filter(w => w.classes().includes('active')); expect(findActiveItems().length).toBe(1); @@ -332,13 +333,13 @@ describe('DateTimePicker', () => { expect(items.length).toBe(Object.keys(otherTimeRanges).length); expect(items.at(0).text()).toBe('1 minute'); - expect(items.at(0).is('.active')).toBe(false); + expect(items.at(0).classes()).not.toContain('active'); expect(items.at(1).text()).toBe('2 minutes'); - expect(items.at(1).is('.active')).toBe(true); + expect(items.at(1).classes()).toContain('active'); expect(items.at(2).text()).toBe('5 minutes'); - expect(items.at(2).is('.active')).toBe(false); + expect(items.at(2).classes()).not.toContain('active'); }); }); diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js index e0e982f4e11..e91e6577aaf 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js @@ -14,19 +14,13 @@ import { const localVue = createLocalVue(); localVue.use(Vuex); -function createRenamedComponent({ - props = {}, - methods = {}, - store = new Vuex.Store({}), - deep = false, -}) { +function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) { const mnt = deep ? mount : shallowMount; return mnt(Renamed, { propsData: { ...props }, localVue, store, - methods, }); } @@ -258,25 +252,17 @@ describe('Renamed Diff Viewer', () => { 'includes a link to the full file for alternate viewer type "$altType"', ({ altType, linkText }) => { const file = { ...diffFile }; - const clickMock = jest.fn().mockImplementation(() => {}); file.alternate_viewer.name = altType; wrapper = createRenamedComponent({ deep: true, props: { diffFile: file }, - methods: { - clickLink: clickMock, - }, }); const link = wrapper.find('a'); expect(link.text()).toEqual(linkText); expect(link.attributes('href')).toEqual(DIFF_FILE_VIEW_PATH); - - link.vm.$emit('click'); - - expect(clickMock).toHaveBeenCalled(); }, ); }); 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 ffdeb25439c..efa30bf6605 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 @@ -32,12 +32,6 @@ describe('DropdownSearchInputComponent', () => { expect(wrapper.find('.fa-search.dropdown-input-search').exists()).toBe(true); }); - it('renders clear search icon element', () => { - expect(wrapper.find('.fa-times.dropdown-input-clear.js-dropdown-input-clear').exists()).toBe( - true, - ); - }); - it('displays custom placeholder text', () => { expect(findInputEl().attributes('placeholder')).toBe(defaultProps.placeholderText); }); diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js index f9e56774526..40026021777 100644 --- a/spec/frontend/vue_shared/components/file_finder/index_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -84,7 +84,7 @@ describe('File finder item spec', () => { waitForPromises() .then(() => { - vm.$el.querySelector('.dropdown-input-clear').click(); + vm.clearSearchInput(); }) .then(waitForPromises) .then(() => { @@ -94,13 +94,13 @@ describe('File finder item spec', () => { .catch(done.fail); }); - it('clear button focues search input', done => { + it('clear button focuses search input', done => { jest.spyOn(vm.$refs.searchInput, 'focus').mockImplementation(() => {}); vm.searchText = 'index'; waitForPromises() .then(() => { - vm.$el.querySelector('.dropdown-input-clear').click(); + vm.clearSearchInput(); }) .then(waitForPromises) .then(() => { @@ -319,8 +319,8 @@ describe('File finder item spec', () => { .catch(done.fail); }); - it('calls toggle on `command+p` key press', done => { - Mousetrap.trigger('command+p'); + it('calls toggle on `mod+p` key press', done => { + Mousetrap.trigger('mod+p'); vm.$nextTick() .then(() => { @@ -330,39 +330,28 @@ describe('File finder item spec', () => { .catch(done.fail); }); - it('calls toggle on `ctrl+p` key press', done => { - Mousetrap.trigger('ctrl+p'); - - vm.$nextTick() - .then(() => { - expect(vm.toggle).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('always allows `command+p` to trigger toggle', () => { + it('always allows `mod+p` to trigger toggle', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'), - ).toBe(false); - }); - - it('always allows `ctrl+p` to trigger toggle', () => { - expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'), + Mousetrap.prototype.stopCallback( + null, + vm.$el.querySelector('.dropdown-input-field'), + 'mod+p', + ), ).toBe(false); }); it('onlys handles `t` when focused in input-field', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), + Mousetrap.prototype.stopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), ).toBe(true); }); it('stops callback in monaco editor', () => { setFixtures('<div class="inputarea"></div>'); - expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); + expect( + Mousetrap.prototype.stopCallback(null, document.querySelector('.inputarea'), 't'), + ).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index 1acd2e05464..d28c35d26bf 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -118,7 +118,7 @@ describe('File row component', () => { level: 0, }); - expect(wrapper.contains(FileHeader)).toBe(true); + expect(wrapper.find(FileHeader).exists()).toBe(true); }); it('matches the current route against encoded file URL', () => { @@ -139,4 +139,16 @@ describe('File row component', () => { expect(wrapper.vm.hasUrlAtCurrentRoute()).toBe(true); }); + + it('render with the correct file classes prop', () => { + createComponent({ + file: { + ...file(), + }, + level: 0, + fileClasses: 'font-weight-bold', + }); + + expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold'); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 73dbecadd89..c79880d4766 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -1,19 +1,28 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { - GlFilteredSearch, - GlButtonGroup, - GlButton, - GlNewDropdown as GlDropdown, - GlNewDropdownItem as GlDropdownItem, -} from '@gitlab/ui'; +import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; +import { + mockAvailableTokens, + mockSortOptions, + mockHistoryItems, + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, +} from './mock_data'; + +jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ + uniqueTokens: jest.fn().mockImplementation(tokens => tokens), + stripQuotes: jest.requireActual( + '~/vue_shared/components/filtered_search_bar/filtered_search_utils', + ).stripQuotes, +})); const createComponent = ({ shallow = true, @@ -52,10 +61,10 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.filterValue).toEqual([]); expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); - expect(wrapper.contains(GlButtonGroup)).toBe(true); - expect(wrapper.contains(GlButton)).toBe(true); - expect(wrapper.contains(GlDropdown)).toBe(true); - expect(wrapper.contains(GlDropdownItem)).toBe(true); + expect(wrapper.find(GlButtonGroup).exists()).toBe(true); + expect(wrapper.find(GlButton).exists()).toBe(true); + expect(wrapper.find(GlDropdown).exists()).toBe(true); + expect(wrapper.find(GlDropdownItem).exists()).toBe(true); }); it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => { @@ -63,23 +72,31 @@ describe('FilteredSearchBarRoot', () => { expect(wrapperNoSort.vm.filterValue).toEqual([]); expect(wrapperNoSort.vm.selectedSortOption).toBe(undefined); - expect(wrapperNoSort.contains(GlButtonGroup)).toBe(false); - expect(wrapperNoSort.contains(GlButton)).toBe(false); - expect(wrapperNoSort.contains(GlDropdown)).toBe(false); - expect(wrapperNoSort.contains(GlDropdownItem)).toBe(false); + expect(wrapperNoSort.find(GlButtonGroup).exists()).toBe(false); + expect(wrapperNoSort.find(GlButton).exists()).toBe(false); + expect(wrapperNoSort.find(GlDropdown).exists()).toBe(false); + expect(wrapperNoSort.find(GlDropdownItem).exists()).toBe(false); }); }); describe('computed', () => { describe('tokenSymbols', () => { it('returns a map containing type and symbols from `tokens` prop', () => { - expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' }); + expect(wrapper.vm.tokenSymbols).toEqual({ + author_username: '@', + label_name: '~', + milestone_title: '%', + }); }); }); describe('tokenTitles', () => { it('returns a map containing type and title from `tokens` prop', () => { - expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' }); + expect(wrapper.vm.tokenTitles).toEqual({ + author_username: 'Author', + label_name: 'Label', + milestone_title: 'Milestone', + }); }); }); @@ -131,6 +148,20 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' }); }); + it('returns array of recent searches sanitizing any duplicate token values', async () => { + wrapper.setData({ + recentSearches: [ + [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel], + [tokenValueAuthor, tokenValueMilestone], + ], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).toHaveLength(2); + expect(uniqueTokens).toHaveBeenCalled(); + }); + it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => { wrapper.setProps({ recentSearchesStorageKey: '', @@ -182,40 +213,12 @@ describe('FilteredSearchBarRoot', () => { }); describe('removeQuotesEnclosure', () => { - const mockFilters = [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: '"Documentation Update"', - operator: '=', - }, - }, - 'foo', - ]; + const mockFilters = [tokenValueAuthor, tokenValueLabel, 'foo']; it('returns filter array with unescaped strings for values which have spaces', () => { expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: 'Documentation Update', - operator: '=', - }, - }, + tokenValueAuthor, + tokenValueLabel, 'foo', ]); }); @@ -277,21 +280,26 @@ describe('FilteredSearchBarRoot', () => { }); describe('handleFilterSubmit', () => { - const mockFilters = [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - 'foo', - ]; + const mockFilters = [tokenValueAuthor, 'foo']; + + beforeEach(async () => { + wrapper.setData({ + filterValue: mockFilters, + }); + + await wrapper.vm.$nextTick(); + }); + + it('calls `uniqueTokens` on `filterValue` prop to remove duplicates', () => { + wrapper.vm.handleFilterSubmit(); + + expect(uniqueTokens).toHaveBeenCalledWith(wrapper.vm.filterValue); + }); it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters); @@ -301,7 +309,7 @@ describe('FilteredSearchBarRoot', () => { it('calls `recentSearchesService.save` with array of searches', () => { jest.spyOn(wrapper.vm.recentSearchesService, 'save'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]); @@ -311,7 +319,7 @@ describe('FilteredSearchBarRoot', () => { it('sets `recentSearches` data prop with array of searches', () => { jest.spyOn(wrapper.vm.recentSearchesService, 'save'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearches).toEqual([mockFilters]); @@ -329,7 +337,7 @@ describe('FilteredSearchBarRoot', () => { it('emits component event `onFilter` with provided filters param', () => { jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters); @@ -366,7 +374,9 @@ describe('FilteredSearchBarRoot', () => { '.gl-search-box-by-click-menu .gl-search-box-by-click-history-item', ); - expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"'); + expect(searchHistoryItemsEl.at(0).text()).toBe( + 'Author := @rootLabel := ~bugMilestone := %v1.0"duo"', + ); wrapperFullMount.destroy(); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index a857f84adf1..4869e75a2f3 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -1,4 +1,18 @@ -import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { + stripQuotes, + uniqueTokens, + prepareTokens, + processFilters, + filterToQueryObject, + urlQueryToFilter, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; + +import { + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValuePlain, +} from './mock_data'; describe('Filtered Search Utils', () => { describe('stripQuotes', () => { @@ -9,11 +23,196 @@ describe('Filtered Search Utils', () => { ${'FooBar'} | ${'FooBar'} ${"Foo'Bar"} | ${"Foo'Bar"} ${'Foo"Bar'} | ${'Foo"Bar'} + ${'Foo Bar'} | ${'Foo Bar'} `( 'returns string $outputValue when called with string $inputValue', ({ inputValue, outputValue }) => { - expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue); + expect(stripQuotes(inputValue)).toBe(outputValue); }, ); }); + + describe('uniqueTokens', () => { + it('returns tokens array with duplicates removed', () => { + expect( + uniqueTokens([ + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValueLabel, + tokenValuePlain, + ]), + ).toHaveLength(4); // Removes 2nd instance of tokenValueLabel + }); + + it('returns tokens array as it is if it does not have duplicates', () => { + expect( + uniqueTokens([tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValuePlain]), + ).toHaveLength(4); + }); + }); +}); + +describe('prepareTokens', () => { + describe('with empty data', () => { + it('returns an empty array', () => { + expect(prepareTokens()).toEqual([]); + expect(prepareTokens({})).toEqual([]); + expect(prepareTokens({ milestone: null, author: null, assignees: [], labels: [] })).toEqual( + [], + ); + }); + }); + + it.each([ + [ + 'milestone', + { value: 'v1.0', operator: '=' }, + [{ type: 'milestone', value: { data: 'v1.0', operator: '=' } }], + ], + [ + 'author', + { value: 'mr.popo', operator: '!=' }, + [{ type: 'author', value: { data: 'mr.popo', operator: '!=' } }], + ], + [ + 'labels', + [{ value: 'z-fighters', operator: '=' }], + [{ type: 'labels', value: { data: 'z-fighters', operator: '=' } }], + ], + [ + 'assignees', + [{ value: 'krillin', operator: '=' }, { value: 'piccolo', operator: '!=' }], + [ + { type: 'assignees', value: { data: 'krillin', operator: '=' } }, + { type: 'assignees', value: { data: 'piccolo', operator: '!=' } }, + ], + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }], + [ + { type: 'foo', value: { data: 'bar', operator: '!=' } }, + { type: 'foo', value: { data: 'baz', operator: '!=' } }, + ], + ], + ])('gathers %s=%j into result=%j', (token, value, result) => { + const res = prepareTokens({ [token]: value }); + expect(res).toEqual(result); + }); +}); + +describe('processFilters', () => { + it('processes multiple filter values', () => { + const result = processFilters([ + { type: 'foo', value: { data: 'foo', operator: '=' } }, + { type: 'bar', value: { data: 'bar1', operator: '=' } }, + { type: 'bar', value: { data: 'bar2', operator: '!=' } }, + ]); + + expect(result).toStrictEqual({ + foo: [{ value: 'foo', operator: '=' }], + bar: [{ value: 'bar1', operator: '=' }, { value: 'bar2', operator: '!=' }], + }); + }); + + it('does not remove wrapping double quotes from the data', () => { + const result = processFilters([ + { type: 'foo', value: { data: '"value with spaces"', operator: '=' } }, + ]); + + expect(result).toStrictEqual({ + foo: [{ value: '"value with spaces"', operator: '=' }], + }); + }); +}); + +describe('filterToQueryObject', () => { + describe('with empty data', () => { + it('returns an empty object', () => { + expect(filterToQueryObject()).toEqual({}); + expect(filterToQueryObject({})).toEqual({}); + expect(filterToQueryObject({ author_username: null, label_name: [] })).toEqual({ + author_username: null, + label_name: null, + 'not[author_username]': null, + 'not[label_name]': null, + }); + }); + }); + + it.each([ + [ + 'author_username', + { value: 'v1.0', operator: '=' }, + { author_username: 'v1.0', 'not[author_username]': null }, + ], + [ + 'author_username', + { value: 'v1.0', operator: '!=' }, + { author_username: null, 'not[author_username]': 'v1.0' }, + ], + [ + 'label_name', + [{ value: 'z-fighters', operator: '=' }], + { label_name: ['z-fighters'], 'not[label_name]': null }, + ], + [ + 'label_name', + [{ value: 'z-fighters', operator: '!=' }], + { label_name: null, 'not[label_name]': ['z-fighters'] }, + ], + [ + 'foo', + [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }], + { foo: ['bar', 'baz'], 'not[foo]': null }, + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }], + { foo: null, 'not[foo]': ['bar', 'baz'] }, + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '=' }], + { foo: ['baz'], 'not[foo]': ['bar'] }, + ], + ])('gathers filter values %s=%j into query object=%j', (token, value, result) => { + const res = filterToQueryObject({ [token]: value }); + expect(res).toEqual(result); + }); +}); + +describe('urlQueryToFilter', () => { + describe('with empty data', () => { + it('returns an empty object', () => { + expect(urlQueryToFilter()).toEqual({}); + expect(urlQueryToFilter('')).toEqual({}); + expect(urlQueryToFilter('author_username=&milestone_title=&')).toEqual({}); + }); + }); + + it.each([ + ['author_username=v1.0', { author_username: { value: 'v1.0', operator: '=' } }], + ['not[author_username]=v1.0', { author_username: { value: 'v1.0', operator: '!=' } }], + ['foo=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }], + ['foo=bar&foo[]=baz', { foo: [{ value: 'baz', operator: '=' }] }], + ['not[foo]=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }], + [ + 'foo[]=bar&foo[]=baz¬[foo]=', + { foo: [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }] }, + ], + [ + 'foo[]=¬[foo][]=bar¬[foo][]=baz', + { foo: [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }] }, + ], + [ + 'foo[]=baz¬[foo][]=bar', + { foo: [{ value: 'baz', operator: '=' }, { value: 'bar', operator: '!=' }] }, + ], + ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }], + ])('gathers filter values %s into query object=%j', (query, result) => { + const res = urlQueryToFilter(query); + expect(res).toEqual(result); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index dcccb1f49b6..e0a3208cac9 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -1,6 +1,7 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -33,6 +34,8 @@ export const mockAuthor3 = { export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; +export const mockBranches = [{ name: 'Master' }, { name: 'v1.x' }, { name: 'my-Branch' }]; + export const mockRegularMilestone = { id: 1, name: '4.0', @@ -55,6 +58,16 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockBranchToken = { + type: 'source_branch', + icon: 'branch', + title: 'Source Branch', + unique: true, + token: BranchToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchBranches: Api.branches.bind(Api), +}; + export const mockAuthorToken = { type: 'author_username', icon: 'user', @@ -89,36 +102,40 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; -export const mockAvailableTokens = [mockAuthorToken, mockLabelToken]; +export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken]; + +export const tokenValueAuthor = { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, +}; + +export const tokenValueLabel = { + type: 'label_name', + value: { + operator: '=', + data: 'bug', + }, +}; + +export const tokenValueMilestone = { + type: 'milestone_title', + value: { + operator: '=', + data: 'v1.0', + }, +}; + +export const tokenValuePlain = { + type: 'filtered-search-term', + value: { data: 'foo' }, +}; export const mockHistoryItems = [ - [ - { - type: 'author_username', - value: { - data: 'toby', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: 'Bug', - operator: '=', - }, - }, - 'duo', - ], - [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - 'si', - ], + [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'], + [tokenValueAuthor, 'si'], ]; export const mockSortOptions = [ diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 160febf9d06..72840ce381f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -1,18 +1,42 @@ import { mount } from '@vue/test-utils'; -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchTokenSegment, + GlFilteredSearchSuggestion, + GlDropdownDivider, +} from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import { mockAuthorToken, mockAuthors } from '../mock_data'; jest.mock('~/flash'); - -const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) => - mount(AuthorToken, { +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockAuthorToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(AuthorToken, { propsData: { config, value, @@ -22,18 +46,9 @@ const createComponent = ({ config = mockAuthorToken, value = { data: '' }, activ portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, }, - stubs: { - Portal: { - template: '<div><slot></slot></div>', - }, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, - }, + stubs, }); +} describe('AuthorToken', () => { let mock; @@ -141,5 +156,57 @@ describe('AuthorToken', () => { expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator" }); }); + + it('renders provided defaultAuthors as suggestions', async () => { + const defaultAuthors = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + wrapper = createComponent({ + active: true, + config: { ...mockAuthorToken, defaultAuthors }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultAuthors.length); + defaultAuthors.forEach((label, index) => { + expect(suggestions.at(index).text()).toBe(label.text); + }); + }); + + it('does not render divider when no defaultAuthors', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockAuthorToken, defaultAuthors: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockAuthorToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(1); + expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js new file mode 100644 index 00000000000..12b7fd58670 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -0,0 +1,207 @@ +import { mount } from '@vue/test-utils'; +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; + +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; + +import { mockBranches, mockBranchToken } from '../mock_data'; + +jest.mock('~/flash'); +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockBranchToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(BranchToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs, + }); +} + +describe('BranchToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: mockBranches[0].name } }); + + wrapper.setData({ + branches: mockBranches, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe('master'); + }); + }); + + describe('activeBranch', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('fetchBranchBySearchTerm', () => { + it('calls `config.fetchBranches` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches'); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `branches` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches }); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.branches).toEqual(mockBranches); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching branches.', + }); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + const defaultBranches = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + async function showSuggestions() { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + } + + beforeEach(async () => { + wrapper = createComponent({ value: { data: mockBranches[0].name } }); + + wrapper.setData({ + branches: mockBranches, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); + expect(tokenSegments.at(2).text()).toBe(mockBranches[0].name); + }); + + it('renders provided defaultBranches as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockBranchToken, defaultBranches }, + stubs: { Portal: true }, + }); + await showSuggestions(); + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultBranches.length); + defaultBranches.forEach((branch, index) => { + expect(suggestions.at(index).text()).toBe(branch.text); + }); + }); + + it('does not render divider when no defaultBranches', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockBranchToken, defaultBranches: [] }, + stubs: { Portal: true }, + }); + await showSuggestions(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders no suggestions as default', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockBranchToken }, + stubs: { Portal: true }, + }); + await showSuggestions(); + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(0); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 0e60ee99327..3feb05bab35 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -1,5 +1,10 @@ import { mount } from '@vue/test-utils'; -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { @@ -9,14 +14,34 @@ import { import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { + DEFAULT_LABELS, + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import { mockLabelToken } from '../mock_data'; jest.mock('~/flash'); - -const createComponent = ({ config = mockLabelToken, value = { data: '' }, active = false } = {}) => - mount(LabelToken, { +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockLabelToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(LabelToken, { propsData: { config, value, @@ -26,18 +51,9 @@ const createComponent = ({ config = mockLabelToken, value = { data: '' }, active portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, }, - stubs: { - Portal: { - template: '<div><slot></slot></div>', - }, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, - }, + stubs, }); +} describe('LabelToken', () => { let mock; @@ -45,7 +61,6 @@ describe('LabelToken', () => { beforeEach(() => { mock = new MockAdapter(axios); - wrapper = createComponent(); }); afterEach(() => { @@ -98,6 +113,10 @@ describe('LabelToken', () => { }); describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + describe('fetchLabelBySearchTerm', () => { it('calls `config.fetchLabels` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels'); @@ -140,6 +159,8 @@ describe('LabelToken', () => { }); describe('template', () => { + const defaultLabels = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); @@ -166,5 +187,58 @@ describe('LabelToken', () => { .attributes('style'), ).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);'); }); + + it('renders provided defaultLabels as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockLabelToken, defaultLabels }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultLabels.length); + defaultLabels.forEach((label, index) => { + expect(suggestions.at(index).text()).toBe(label.text); + }); + }); + + it('does not render divider when no defaultLabels', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockLabelToken, defaultLabels: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders `DEFAULT_LABELS` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockLabelToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_LABELS.length); + DEFAULT_LABELS.forEach((label, index) => { + expect(suggestions.at(index).text()).toBe(label.text); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index de893bf44c8..0ec814e3f15 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -1,10 +1,16 @@ import { mount } from '@vue/test-utils'; -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; +import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import { @@ -16,12 +22,24 @@ import { jest.mock('~/flash'); -const createComponent = ({ - config = mockMilestoneToken, - value = { data: '' }, - active = false, -} = {}) => - mount(MilestoneToken, { +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockMilestoneToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(MilestoneToken, { propsData: { config, value, @@ -31,18 +49,9 @@ const createComponent = ({ portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, }, - stubs: { - Portal: { - template: '<div><slot></slot></div>', - }, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, - }, + stubs, }); +} describe('MilestoneToken', () => { let mock; @@ -128,6 +137,8 @@ describe('MilestoneToken', () => { }); describe('template', () => { + const defaultMilestones = [{ text: 'foo', value: 'foo' }, { text: 'bar', value: 'baz' }]; + beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } }); @@ -146,7 +157,60 @@ describe('MilestoneToken', () => { const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"' - expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1" + expect(tokenSegments.at(2).text()).toBe(`%${mockRegularMilestone.title}`); // "4.0 RC1" + }); + + it('renders provided defaultMilestones as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken, defaultMilestones }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultMilestones.length); + defaultMilestones.forEach((milestone, index) => { + expect(suggestions.at(index).text()).toBe(milestone.text); + }); + }); + + it('does not render divider when no defaultMilestones', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken, defaultMilestones: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders `DEFAULT_MILESTONES` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_MILESTONES.length); + DEFAULT_MILESTONES.forEach((milestone, index) => { + expect(suggestions.at(index).text()).toBe(milestone.text); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js deleted file mode 100644 index 87cafa0bb8c..00000000000 --- a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js +++ /dev/null @@ -1,190 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/vue_shared/components/filtered_search_dropdown.vue'; - -describe('Filtered search dropdown', () => { - const Component = Vue.extend(component); - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('with an empty array of items', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [], - filterKey: '', - }); - }); - - it('renders empty list', () => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); - }); - - it('renders filter input', () => { - expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull(); - }); - }); - - describe('when visible numbers is less than the items length', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], - visibleItems: 2, - filterKey: 'title', - }); - }); - - it('it renders only the maximum number provided', () => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); - }); - }); - - describe('when visible number is bigger than the items length', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], - filterKey: 'title', - }); - }); - - it('it renders the full list of items the maximum number provided', () => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3); - }); - }); - - describe('while filtering', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - }); - }); - - it('updates the results to match the typed value', done => { - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); - done(); - }); - }); - - describe('when no value matches the typed one', () => { - it('does not render any result', done => { - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); - done(); - }); - }); - }); - }); - - describe('with create mode enabled', () => { - describe('when there are no matches', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - showCreateMode: true, - }); - - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - }); - - it('renders a create button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull(); - done(); - }); - }); - - it('renders computed button text', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual( - 'Create eleven', - ); - done(); - }); - }); - - describe('on click create button', () => { - it('emits createItem event with the filter', done => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.$nextTick(() => { - vm.$el.querySelector('.js-dropdown-create-button').click(); - - expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven'); - done(); - }); - }); - }); - }); - - describe('when there are matches', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - showCreateMode: true, - }); - - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - }); - - it('does not render a create button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); - done(); - }); - }); - }); - }); - - describe('with create mode disabled', () => { - describe('when there are no matches', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - }); - - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - }); - - it('does not render a create button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); - done(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/icon_spec.js b/spec/frontend/vue_shared/components/icon_spec.js deleted file mode 100644 index 16728e1705a..00000000000 --- a/spec/frontend/vue_shared/components/icon_spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import Vue from 'vue'; -import { mount } from '@vue/test-utils'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import iconsPath from '@gitlab/svgs/dist/icons.svg'; -import Icon from '~/vue_shared/components/icon.vue'; - -jest.mock('@gitlab/svgs/dist/icons.svg', () => 'testing'); - -describe('Sprite Icon Component', () => { - describe('Initialization', () => { - let icon; - - beforeEach(() => { - const IconComponent = Vue.extend(Icon); - - icon = mountComponent(IconComponent, { - name: 'commit', - size: 32, - }); - }); - - afterEach(() => { - icon.$destroy(); - }); - - it('should return a defined Vue component', () => { - expect(icon).toBeDefined(); - }); - - it('should have <svg> as a child element', () => { - expect(icon.$el.tagName).toBe('svg'); - }); - - it('should have <use> as a child element with the correct href', () => { - expect(icon.$el.firstChild.tagName).toBe('use'); - expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${iconsPath}#commit`); - }); - - it('should properly compute iconSizeClass', () => { - expect(icon.iconSizeClass).toBe('s32'); - }); - - it('forbids invalid size prop', () => { - expect(icon.$options.props.size.validator(NaN)).toBeFalsy(); - expect(icon.$options.props.size.validator(0)).toBeFalsy(); - expect(icon.$options.props.size.validator(9001)).toBeFalsy(); - }); - - it('should properly render img css', () => { - const { classList } = icon.$el; - const containsSizeClass = classList.contains('s32'); - - expect(containsSizeClass).toBe(true); - }); - - it('`name` validator should return false for non existing icons', () => { - jest.spyOn(console, 'warn').mockImplementation(); - - expect(Icon.props.name.validator('non_existing_icon_sprite')).toBe(false); - }); - - it('`name` validator should return true for existing icons', () => { - expect(Icon.props.name.validator('commit')).toBe(true); - }); - }); - - it('should call registered listeners when they are triggered', () => { - const clickHandler = jest.fn(); - const wrapper = mount(Icon, { - propsData: { name: 'commit' }, - listeners: { click: clickHandler }, - }); - - wrapper.find('svg').trigger('click'); - - expect(clickHandler).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index b72f78c4f60..c87d19df1f7 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import { mockMilestone } from 'jest/boards/mock_data'; +import { GlIcon } from '@gitlab/ui'; import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const createComponent = (milestone = mockMilestone) => { const Component = Vue.extend(IssueMilestone); @@ -135,7 +135,7 @@ describe('IssueMilestoneComponent', () => { }); it('renders milestone icon', () => { - expect(wrapper.find(Icon).props('name')).toBe('clock'); + expect(wrapper.find(GlIcon).props('name')).toBe('clock'); }); it('renders milestone title', () => { diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index fb9487d0bf8..2319bf61482 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -35,6 +35,9 @@ describe('RelatedIssuableItem', () => { weight: '<div class="js-weight-slot"></div>', }; + const findRemoveButton = () => wrapper.find({ ref: 'removeButton' }); + const findLockIcon = () => wrapper.find({ ref: 'lockIcon' }); + beforeEach(() => { mountComponent({ props, slots }); }); @@ -121,10 +124,10 @@ describe('RelatedIssuableItem', () => { }); it('renders milestone icon and name', () => { - const milestoneIcon = tokenMetadata().find('.item-milestone svg use'); + const milestoneIcon = tokenMetadata().find('.item-milestone svg'); const milestoneTitle = tokenMetadata().find('.item-milestone .milestone-title'); - expect(milestoneIcon.attributes('href')).toContain('clock'); + expect(milestoneIcon.attributes('data-testid')).toBe('clock-icon'); expect(milestoneTitle.text()).toContain('Milestone title'); }); @@ -143,25 +146,27 @@ describe('RelatedIssuableItem', () => { }); describe('remove button', () => { - const removeButton = () => wrapper.find({ ref: 'removeButton' }); - beforeEach(() => { wrapper.setProps({ canRemove: true }); }); it('renders if canRemove', () => { - expect(removeButton().exists()).toBe(true); + expect(findRemoveButton().exists()).toBe(true); + }); + + it('does not render the lock icon', () => { + expect(findLockIcon().exists()).toBe(false); }); it('renders disabled button when removeDisabled', async () => { wrapper.setData({ removeDisabled: true }); await wrapper.vm.$nextTick(); - expect(removeButton().attributes('disabled')).toEqual('disabled'); + expect(findRemoveButton().attributes('disabled')).toEqual('disabled'); }); it('triggers onRemoveRequest when clicked', async () => { - removeButton().trigger('click'); + findRemoveButton().trigger('click'); await wrapper.vm.$nextTick(); const { relatedIssueRemoveRequest } = wrapper.emitted(); @@ -169,4 +174,23 @@ describe('RelatedIssuableItem', () => { expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); }); }); + + describe('when issue is locked', () => { + const lockedMessage = 'Issues created from a vulnerability cannot be removed'; + + beforeEach(() => { + wrapper.setProps({ + isLocked: true, + lockedMessage, + }); + }); + + it('does not render the remove button', () => { + expect(findRemoveButton().exists()).toBe(false); + }); + + it('renders the lock icon with the correct title', () => { + expect(findLockIcon().attributes('title')).toBe(lockedMessage); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 551d781d296..82bc9b9fe08 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -22,6 +22,12 @@ describe('Markdown field header component', () => { .at(0); beforeEach(() => { + window.gl = { + client: { + isMac: true, + }, + }; + createWrapper(); }); @@ -30,24 +36,40 @@ describe('Markdown field header component', () => { wrapper = null; }); - it('renders markdown header buttons', () => { - const buttons = [ - 'Add bold text', - 'Add italic text', - 'Insert a quote', - 'Insert suggestion', - 'Insert code', - 'Add a link', - 'Add a bullet list', - 'Add a numbered list', - 'Add a task list', - 'Add a table', - 'Go full screen', - ]; - const elements = findToolbarButtons(); - - elements.wrappers.forEach((buttonEl, index) => { - expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); + describe('markdown header buttons', () => { + it('renders the buttons with the correct title', () => { + const buttons = [ + 'Add bold text (⌘B)', + 'Add italic text (⌘I)', + 'Insert a quote', + 'Insert suggestion', + 'Insert code', + 'Add a link (⌘K)', + 'Add a bullet list', + 'Add a numbered list', + 'Add a task list', + 'Add a table', + 'Go full screen', + ]; + const elements = findToolbarButtons(); + + elements.wrappers.forEach((buttonEl, index) => { + expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); + }); + }); + + describe('when the user is on a non-Mac', () => { + beforeEach(() => { + delete window.gl.client.isMac; + + createWrapper(); + }); + + it('renders keyboard shortcuts with Ctrl+ instead of ⌘', () => { + const boldButton = findToolbarButtonByProp('icon', 'bold'); + + expect(boldButton.props('buttonTitle')).toBe('Add bold text (Ctrl+B)'); + }); }); }); 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 c6e147899e4..a521668b15c 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 @@ -77,10 +77,7 @@ describe('Suggestion Diff component', () => { }); it('emits apply', () => { - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'apply', - args: [expect.any(Function)], - }); + expect(wrapper.emitted().apply).toEqual([[expect.any(Function)]]); }); it('does not render apply suggestion and add to batch buttons', () => { @@ -111,10 +108,7 @@ describe('Suggestion Diff component', () => { findAddToBatchButton().vm.$emit('click'); - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'addToBatch', - args: [], - }); + expect(wrapper.emitted().addToBatch).toEqual([[]]); }); }); @@ -124,10 +118,7 @@ describe('Suggestion Diff component', () => { findRemoveFromBatchButton().vm.$emit('click'); - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'removeFromBatch', - args: [], - }); + expect(wrapper.emitted().removeFromBatch).toEqual([[]]); }); }); @@ -137,10 +128,7 @@ describe('Suggestion Diff component', () => { findApplyBatchButton().vm.$emit('click'); - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'applyBatch', - args: [], - }); + expect(wrapper.emitted().applyBatch).toEqual([[]]); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js index 6ae405017c9..b67f4cf12bf 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js @@ -34,12 +34,25 @@ describe('SuggestionDiffRow', () => { const findOldLineWrapper = () => wrapper.find('.old_line'); const findNewLineWrapper = () => wrapper.find('.new_line'); + const findSuggestionContent = () => wrapper.find('[data-testid="suggestion-diff-content"]'); afterEach(() => { wrapper.destroy(); }); describe('renders correctly', () => { + it('renders the correct base suggestion markup', () => { + factory({ + propsData: { + line: oldLine, + }, + }); + + expect(findSuggestionContent().html()).toBe( + '<td data-testid="suggestion-diff-content" class="line_content old"><span class="line">oldrichtext</span></td>', + ); + }); + it('has the right classes on the wrapper', () => { factory({ propsData: { @@ -47,7 +60,12 @@ describe('SuggestionDiffRow', () => { }, }); - expect(wrapper.is('.line_holder')).toBe(true); + expect(wrapper.classes()).toContain('line_holder'); + expect( + findSuggestionContent() + .find('span') + .classes(), + ).toContain('line'); }); it('renders the rich text when it is available', () => { diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js new file mode 100644 index 00000000000..8a7946fd7b1 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue'; + +describe('toolbar_button', () => { + let wrapper; + + const defaultProps = { + buttonTitle: 'test button', + icon: 'rocket', + tag: 'test tag', + }; + + const createComponent = propUpdates => { + wrapper = shallowMount(ToolbarButton, { + propsData: { + ...defaultProps, + ...propUpdates, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const getButtonShortcutsAttr = () => { + return wrapper.find('button').attributes('data-md-shortcuts'); + }; + + describe('keyboard shortcuts', () => { + it.each` + shortcutsProp | mdShortcutsAttr + ${undefined} | ${JSON.stringify([])} + ${[]} | ${JSON.stringify([])} + ${'command+b'} | ${JSON.stringify(['command+b'])} + ${['command+b', 'ctrl+b']} | ${JSON.stringify(['command+b', 'ctrl+b'])} + `( + 'adds the attribute data-md-shortcuts="$mdShortcutsAttr" to the button when the shortcuts prop is $shortcutsProp', + ({ shortcutsProp, mdShortcutsAttr }) => { + createComponent({ shortcuts: shortcutsProp }); + + expect(getButtonShortcutsAttr()).toBe(mdShortcutsAttr); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js index ae8c9a0928e..61660f79b71 100644 --- a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js +++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('Issue Warning Component', () => { let wrapper; - const findIcon = (w = wrapper) => w.find(Icon); + const findIcon = (w = wrapper) => w.find(GlIcon); const findLockedBlock = (w = wrapper) => w.find({ ref: 'locked' }); const findConfidentialBlock = (w = wrapper) => w.find({ ref: 'confidential' }); const findLockedAndConfidentialBlock = (w = wrapper) => w.find({ ref: 'lockedAndConfidential' }); @@ -69,7 +69,7 @@ describe('Issue Warning Component', () => { }); it('renders warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); }); it('does not render information about locked noteable', () => { @@ -95,7 +95,7 @@ describe('Issue Warning Component', () => { }); it('does not render warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(false); + expect(wrapper.find(GlIcon).exists()).toBe(false); }); it('does not render information about locked noteable', () => { diff --git a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js index f73d3edec5d..bd4b6a463ab 100644 --- a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js +++ b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js @@ -17,9 +17,9 @@ describe(`TimelineEntryItem`, () => { it('renders correctly', () => { factory(); - expect(wrapper.is('.timeline-entry')).toBe(true); + expect(wrapper.classes()).toContain('timeline-entry'); - expect(wrapper.contains('.timeline-entry-inner')).toBe(true); + expect(wrapper.find('.timeline-entry-inner').exists()).toBe(true); }); it('accepts default slot', () => { diff --git a/spec/frontend/vue_shared/components/ordered_layout_spec.js b/spec/frontend/vue_shared/components/ordered_layout_spec.js index e8667d9ee4a..eec153c3792 100644 --- a/spec/frontend/vue_shared/components/ordered_layout_spec.js +++ b/spec/frontend/vue_shared/components/ordered_layout_spec.js @@ -27,7 +27,9 @@ describe('Ordered Layout', () => { let wrapper; const verifyOrder = () => - wrapper.findAll('footer,header').wrappers.map(x => (x.is('footer') ? 'footer' : 'header')); + wrapper + .findAll('footer,header') + .wrappers.map(x => (x.element.tagName === 'FOOTER' ? 'footer' : 'header')); const createComponent = (props = {}) => { wrapper = mount(TestComponent, { diff --git a/spec/frontend/vue_shared/components/paginated_list_spec.js b/spec/frontend/vue_shared/components/paginated_list_spec.js index 46e45296c37..c0ee49f194f 100644 --- a/spec/frontend/vue_shared/components/paginated_list_spec.js +++ b/spec/frontend/vue_shared/components/paginated_list_spec.js @@ -48,7 +48,7 @@ describe('Pagination links component', () => { describe('rendering', () => { it('it renders the gl-paginated-list', () => { - expect(wrapper.contains('ul.list-group')).toBe(true); + expect(wrapper.find('ul.list-group').exists()).toBe(true); expect(wrapper.findAll('li.list-group-item').length).toBe(2); }); }); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 385134c4a3f..649eb2643f1 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -29,7 +29,7 @@ describe('ProjectListItem component', () => { it('does not render a check mark icon if selected === false', () => { wrapper = shallowMount(Component, options); - expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true); + expect(wrapper.find('.js-selected-icon').exists()).toBe(false); }); it('renders a check mark icon if selected === true', () => { @@ -37,7 +37,7 @@ describe('ProjectListItem component', () => { wrapper = shallowMount(Component, options); - expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true); + expect(wrapper.find('.js-selected-icon').exists()).toBe(true); }); it(`emits a "clicked" event when clicked`, () => { @@ -53,7 +53,7 @@ describe('ProjectListItem component', () => { it(`renders the project avatar`, () => { wrapper = shallowMount(Component, options); - expect(wrapper.contains('.js-project-avatar')).toBe(true); + expect(wrapper.find('.js-project-avatar').exists()).toBe(true); }); it(`renders a simple namespace name with a trailing slash`, () => { 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 new file mode 100644 index 00000000000..16094a42668 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Package code instruction multiline to match the snapshot 1`] = ` +<div> + <pre + class="gl-font-monospace" + data-testid="multiline-instruction" + > + this is some +multiline text + </pre> +</div> +`; + +exports[`Package code instruction single line to match the default snapshot 1`] = ` +<div + class="gl-mb-3" +> + <label + for="instruction-input_2" + > + foo_label + </label> + + <div + class="input-group gl-mb-3" + > + <input + class="form-control gl-font-monospace" + data-testid="instruction-input" + id="instruction-input_2" + readonly="readonly" + type="text" + /> + + <span + class="input-group-append" + data-testid="instruction-button" + > + <button + class="btn input-group-text btn-secondary btn-md btn-default" + data-clipboard-text="npm i @my-package" + title="Copy npm install command" + type="button" + > + <!----> + + <svg + class="gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + </button> + </span> + </div> +</div> +`; diff --git a/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap index a1751d69c70..2abae33bc19 100644 --- a/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`History Element renders the correct markup 1`] = ` +exports[`History Item renders the correct markup 1`] = ` <li class="timeline-entry system-note note-wrapper gl-mb-6!" > @@ -31,7 +31,11 @@ exports[`History Element renders the correct markup 1`] = ` <div class="note-body" - /> + > + <div + data-testid="body-slot" + /> + </div> </div> </div> </li> diff --git a/spec/frontend/packages/details/components/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js index 724eddb9070..84c738764a3 100644 --- a/spec/frontend/packages/details/components/code_instruction_spec.js +++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; -import CodeInstruction from '~/packages/details/components/code_instruction.vue'; -import { TrackingLabels } from '~/packages/details/constants'; import Tracking from '~/tracking'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; describe('Package code instruction', () => { let wrapper; @@ -20,16 +20,20 @@ describe('Package code instruction', () => { }); } - const findInstructionInput = () => wrapper.find('.js-instruction-input'); - const findInstructionPre = () => wrapper.find('.js-instruction-pre'); - const findInstructionButton = () => wrapper.find('.js-instruction-button'); + const findCopyButton = () => wrapper.find(ClipboardButton); + const findInputElement = () => wrapper.find('[data-testid="instruction-input"]'); + const findMultilineInstruction = () => wrapper.find('[data-testid="multiline-instruction"]'); afterEach(() => { wrapper.destroy(); }); describe('single line', () => { - beforeEach(() => createComponent()); + beforeEach(() => + createComponent({ + label: 'foo_label', + }), + ); it('to match the default snapshot', () => { expect(wrapper.element).toMatchSnapshot(); @@ -41,6 +45,7 @@ describe('Package code instruction', () => { createComponent({ instruction: 'this is some\nmultiline text', copyText: 'Copy the command', + label: 'foo_label', multiline: true, }), ); @@ -53,7 +58,7 @@ describe('Package code instruction', () => { describe('tracking', () => { let eventSpy; const trackingAction = 'test_action'; - const label = TrackingLabels.CODE_INSTRUCTION; + const trackingLabel = 'foo_label'; beforeEach(() => { eventSpy = jest.spyOn(Tracking, 'event'); @@ -61,7 +66,7 @@ describe('Package code instruction', () => { it('should not track when no trackingAction is provided', () => { createComponent(); - findInstructionButton().trigger('click'); + findCopyButton().trigger('click'); expect(eventSpy).toHaveBeenCalledTimes(0); }); @@ -70,22 +75,23 @@ describe('Package code instruction', () => { beforeEach(() => createComponent({ trackingAction, + trackingLabel, }), ); it('should track when copying from the input', () => { - findInstructionInput().trigger('copy'); + findInputElement().trigger('copy'); expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { - label, + label: trackingLabel, }); }); it('should track when the copy button is pressed', () => { - findInstructionButton().trigger('click'); + findCopyButton().trigger('click'); expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { - label, + label: trackingLabel, }); }); }); @@ -94,15 +100,16 @@ describe('Package code instruction', () => { beforeEach(() => createComponent({ trackingAction, + trackingLabel, multiline: true, }), ); it('should track when copying from the multiline pre element', () => { - findInstructionPre().trigger('copy'); + findMultilineInstruction().trigger('copy'); expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { - label, + label: trackingLabel, }); }); }); diff --git a/spec/frontend/registry/shared/components/details_row_spec.js b/spec/frontend/vue_shared/components/registry/details_row_spec.js index 5ae4e0ab37f..16a55b84787 100644 --- a/spec/frontend/registry/shared/components/details_row_spec.js +++ b/spec/frontend/vue_shared/components/registry/details_row_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; -import component from '~/registry/shared/components/details_row.vue'; +import component from '~/vue_shared/components/registry/details_row.vue'; describe('DetailsRow', () => { let wrapper; diff --git a/spec/frontend/packages/details/components/history_element_spec.js b/spec/frontend/vue_shared/components/registry/history_item_spec.js index e8746fc93f5..d51ddda2e3e 100644 --- a/spec/frontend/packages/details/components/history_element_spec.js +++ b/spec/frontend/vue_shared/components/registry/history_item_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; -import component from '~/packages/details/components/history_element.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import component from '~/vue_shared/components/registry/history_item.vue'; -describe('History Element', () => { +describe('History Item', () => { let wrapper; const defaultProps = { icon: 'pencil', @@ -17,6 +17,7 @@ describe('History Element', () => { }, slots: { default: '<div data-testid="default-slot"></div>', + body: '<div data-testid="body-slot"></div>', }, }); }; @@ -29,6 +30,7 @@ describe('History Element', () => { const findTimelineEntry = () => wrapper.find(TimelineEntryItem); const findGlIcon = () => wrapper.find(GlIcon); const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + const findBodySlot = () => wrapper.find('[data-testid="body-slot"]'); it('renders the correct markup', () => { mountComponent(); @@ -41,11 +43,19 @@ describe('History Element', () => { expect(findDefaultSlot().exists()).toBe(true); }); + + it('has a body slot', () => { + mountComponent(); + + expect(findBodySlot().exists()).toBe(true); + }); + it('has a timeline entry', () => { mountComponent(); expect(findTimelineEntry().exists()).toBe(true); }); + it('has an icon', () => { mountComponent(); diff --git a/spec/frontend/registry/explorer/components/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js index f244627a8c3..e2cfdedb4bf 100644 --- a/spec/frontend/registry/explorer/components/list_item_spec.js +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -1,6 +1,6 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import component from '~/registry/explorer/components/list_item.vue'; +import component from '~/vue_shared/components/registry/list_item.vue'; describe('list item', () => { let wrapper; @@ -34,7 +34,7 @@ describe('list item', () => { wrapper = null; }); - it.each` + describe.each` slotName | finderFunction ${'left-primary'} | ${findLeftPrimarySlot} ${'left-secondary'} | ${findLeftSecondarySlot} @@ -42,10 +42,18 @@ describe('list item', () => { ${'right-secondary'} | ${findRightSecondarySlot} ${'left-action'} | ${findLeftActionSlot} ${'right-action'} | ${findRightActionSlot} - `('has a $slotName slot', ({ finderFunction }) => { - mountComponent(); + `('$slotName slot', ({ finderFunction, slotName }) => { + it('exist when the slot is filled', () => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); - expect(finderFunction().exists()).toBe(true); + it('does not exist when the slot is empty', () => { + mountComponent({}, { [slotName]: '' }); + + expect(finderFunction().exists()).toBe(false); + }); }); describe.each` @@ -106,51 +114,22 @@ describe('list item', () => { }); }); - describe('first prop', () => { - it('when is true displays a double top border', () => { - mountComponent({ first: true }); - - expect(wrapper.classes('gl-border-t-2')).toBe(true); - }); - - it('when is false display a single top border', () => { - mountComponent({ first: false }); - - expect(wrapper.classes('gl-border-t-1')).toBe(true); - }); - }); - - describe('last prop', () => { - it('when is true displays a double bottom border', () => { - mountComponent({ last: true }); - - expect(wrapper.classes('gl-border-b-2')).toBe(true); - }); - - it('when is false display a single bottom border', () => { - mountComponent({ last: false }); - - expect(wrapper.classes('gl-border-b-1')).toBe(true); - }); - }); - - describe('selected prop', () => { - it('when true applies the selected border and background', () => { - mountComponent({ selected: true }); - - expect(wrapper.classes()).toEqual( - expect.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']), - ); - expect(wrapper.classes()).toEqual(expect.not.arrayContaining(['gl-border-gray-100'])); - }); - - it('when false applies the default border', () => { - mountComponent({ selected: false }); - - expect(wrapper.classes()).toEqual( - expect.not.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']), - ); - expect(wrapper.classes()).toEqual(expect.arrayContaining(['gl-border-gray-100'])); - }); + describe('borders and selection', () => { + it.each` + first | selected | shouldHave | shouldNotHave + ${true} | ${true} | ${['gl-bg-blue-50', 'gl-border-blue-200']} | ${['gl-border-t-transparent', 'gl-border-t-gray-100']} + ${false} | ${true} | ${['gl-bg-blue-50', 'gl-border-blue-200']} | ${['gl-border-t-transparent', 'gl-border-t-gray-100']} + ${true} | ${false} | ${['gl-border-b-gray-100']} | ${['gl-bg-blue-50', 'gl-border-blue-200']} + ${false} | ${false} | ${['gl-border-b-gray-100']} | ${['gl-bg-blue-50', 'gl-border-blue-200']} + `( + 'when first is $first and selected is $selected', + ({ first, selected, shouldHave, shouldNotHave }) => { + mountComponent({ first, selected }); + + expect(wrapper.classes()).toEqual(expect.arrayContaining(shouldHave)); + + expect(wrapper.classes()).toEqual(expect.not.arrayContaining(shouldNotHave)); + }, + ); }); }); diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js new file mode 100644 index 00000000000..ff968ff1831 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js @@ -0,0 +1,101 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import component from '~/vue_shared/components/registry/metadata_item.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; + +describe('Metadata Item', () => { + let wrapper; + const defaultProps = { + text: 'foo', + }; + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findIcon = () => wrapper.find(GlIcon); + const findLink = (w = wrapper) => w.find(GlLink); + const findText = () => wrapper.find('[data-testid="metadata-item-text"]'); + const findTooltipOnTruncate = (w = wrapper) => w.find(TooltipOnTruncate); + + describe.each(['xs', 's', 'm', 'l', 'xl'])('size class', size => { + const className = `mw-${size}`; + + it(`${size} is assigned correctly to text`, () => { + mountComponent({ ...defaultProps, size }); + + expect(findText().classes()).toContain(className); + }); + + it(`${size} is assigned correctly to link`, () => { + mountComponent({ ...defaultProps, link: 'foo', size }); + + expect(findTooltipOnTruncate().classes()).toContain(className); + }); + }); + + describe('text', () => { + it('display a proper text', () => { + mountComponent(); + + expect(findText().text()).toBe(defaultProps.text); + }); + + it('uses tooltip_on_truncate', () => { + mountComponent(); + + const tooltip = findTooltipOnTruncate(findText()); + expect(tooltip.exists()).toBe(true); + expect(tooltip.attributes('title')).toBe(defaultProps.text); + }); + }); + + describe('link', () => { + it('if a link prop is passed shows a link and hides the text', () => { + mountComponent({ ...defaultProps, link: 'bar' }); + + expect(findLink().exists()).toBe(true); + expect(findText().exists()).toBe(false); + + expect(findLink().attributes('href')).toBe('bar'); + }); + + it('uses tooltip_on_truncate', () => { + mountComponent({ ...defaultProps, link: 'bar' }); + + const tooltip = findTooltipOnTruncate(); + expect(tooltip.exists()).toBe(true); + expect(tooltip.attributes('title')).toBe(defaultProps.text); + expect(findLink(tooltip).exists()).toBe(true); + }); + + it('hides the link and shows the test if a link prop is not passed', () => { + mountComponent(); + + expect(findText().exists()).toBe(true); + expect(findLink().exists()).toBe(false); + }); + }); + + describe('icon', () => { + it('if a icon prop is passed shows a icon', () => { + mountComponent({ ...defaultProps, icon: 'pencil' }); + + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('pencil'); + }); + + it('if a icon prop is not passed hides the icon', () => { + mountComponent(); + + expect(findIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js new file mode 100644 index 00000000000..6740d6097a4 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -0,0 +1,98 @@ +import { GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/vue_shared/components/registry/title_area.vue'; + +describe('title area', () => { + let wrapper; + + const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]'); + const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]'); + const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`); + const findTitle = () => wrapper.find('[data-testid="title"]'); + const findAvatar = () => wrapper.find(GlAvatar); + + const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { + wrapper = shallowMount(component, { + propsData, + slots: { + 'sub-header': '<div data-testid="sub-header" />', + 'right-actions': '<div data-testid="right-actions" />', + ...slots, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('title', () => { + it('if slot is not present defaults to prop', () => { + mountComponent(); + + expect(findTitle().text()).toBe('foo'); + }); + it('if slot is present uses slot', () => { + mountComponent({ + slots: { + title: 'slot_title', + }, + }); + expect(findTitle().text()).toBe('slot_title'); + }); + }); + + describe('avatar', () => { + it('is shown if avatar props exist', () => { + mountComponent({ propsData: { title: 'foo', avatar: 'baz' } }); + + expect(findAvatar().props('src')).toBe('baz'); + }); + + it('is hidden if avatar props does not exist', () => { + mountComponent(); + + expect(findAvatar().exists()).toBe(false); + }); + }); + + describe.each` + slotName | finderFunction + ${'sub-header'} | ${findSubHeaderSlot} + ${'right-actions'} | ${findRightActionsSlot} + `('$slotName slot', ({ finderFunction, slotName }) => { + it('exist when the slot is filled', () => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); + + it('does not exist when the slot is empty', () => { + mountComponent({ slots: { [slotName]: '' } }); + + expect(finderFunction().exists()).toBe(false); + }); + }); + + describe.each` + slotNames + ${['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}" />`; + return acc; + }, {}); + + it('exist when the slot is present', async () => { + mountComponent({ slots: slotMocks }); + + await wrapper.vm.$nextTick(); + slotNames.forEach(name => { + expect(findMetadataSlot(name).exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js index 2d380b25a0a..78fe6d53eee 100644 --- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js +++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js @@ -48,7 +48,7 @@ describe('RemoveMemberModal', () => { }); it(`${checkboxTestDescription}`, () => { - expect(wrapper.contains(GlFormCheckbox)).toBe(checkboxExpected); + expect(wrapper.find(GlFormCheckbox).exists()).toBe(checkboxExpected); }); it('submits the form when the modal is submitted', () => { diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap index 103b53cb280..3990248d021 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -1,324 +1,433 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Resizable Skeleton Loader default setup renders the bars, labels, and grid with correct position, size, and rx percentages 1`] = ` -<gl-skeleton-loader-stub - baseurl="" - height="130" - preserveaspectratio="xMidYMid meet" - width="400" +<div + class="gl-px-8" > - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="30%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="60%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="90%" - /> - - <rect - data-testid="skeleton-chart-bar" - height="5%" - rx="0.4%" - width="6%" - x="5.875%" - y="85%" - /> - <rect - data-testid="skeleton-chart-bar" - height="7%" - rx="0.4%" - width="6%" - x="17.625%" - y="83%" - /> - <rect - data-testid="skeleton-chart-bar" - height="9%" - rx="0.4%" - width="6%" - x="29.375%" - y="81%" - /> - <rect - data-testid="skeleton-chart-bar" - height="14%" - rx="0.4%" - width="6%" - x="41.125%" - y="76%" - /> - <rect - data-testid="skeleton-chart-bar" - height="21%" - rx="0.4%" - width="6%" - x="52.875%" - y="69%" - /> - <rect - data-testid="skeleton-chart-bar" - height="35%" - rx="0.4%" - width="6%" - x="64.625%" - y="55%" - /> - <rect - data-testid="skeleton-chart-bar" - height="50%" - rx="0.4%" - width="6%" - x="76.375%" - y="40%" - /> - <rect - data-testid="skeleton-chart-bar" - height="80%" - rx="0.4%" - width="6%" - x="88.125%" - y="10%" - /> - - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="6.875%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="18.625%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="30.375%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="42.125%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="53.875%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="65.625%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="77.375%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="89.125%" - y="95%" - /> -</gl-skeleton-loader-stub> + <svg + class="gl-skeleton-loader" + preserveAspectRatio="xMidYMid meet" + version="1.1" + viewBox="0 0 400 130" + > + <rect + clip-path="url(#null-idClip)" + height="130" + style="fill: url(#null-idGradient);" + width="400" + x="0" + y="0" + /> + <defs> + <clippath + id="null-idClip" + > + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.4%" + width="4%" + x="6%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.4%" + width="4%" + x="18%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.4%" + width="4%" + x="30%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.4%" + width="4%" + x="42%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.4%" + width="4%" + x="54%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.4%" + width="4%" + x="66%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.4%" + width="4%" + x="78%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.4%" + width="4%" + x="90%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="6.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="18.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="30.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="42.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="54.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="66.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="78.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="90.5%" + y="97%" + /> + </clippath> + <lineargradient + id="null-idGradient" + > + <stop + class="primary-stop" + offset="0%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-2; 1" + /> + </stop> + <stop + class="secondary-stop" + offset="50%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1.5; 1.5" + /> + </stop> + <stop + class="primary-stop" + offset="100%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1; 2" + /> + </stop> + </lineargradient> + </defs> + </svg> +</div> `; exports[`Resizable Skeleton Loader with custom settings renders the correct position, and size percentages for bars and labels with different settings 1`] = ` -<gl-skeleton-loader-stub - baseurl="" - height="130" - preserveaspectratio="xMidYMid meet" - uniquekey="" - width="400" +<div + class="gl-px-8" > - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="30%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="60%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="90%" - /> - - <rect - data-testid="skeleton-chart-bar" - height="5%" - rx="0.6%" - width="3%" - x="6.0625%" - y="85%" - /> - <rect - data-testid="skeleton-chart-bar" - height="7%" - rx="0.6%" - width="3%" - x="18.1875%" - y="83%" - /> - <rect - data-testid="skeleton-chart-bar" - height="9%" - rx="0.6%" - width="3%" - x="30.3125%" - y="81%" - /> - <rect - data-testid="skeleton-chart-bar" - height="14%" - rx="0.6%" - width="3%" - x="42.4375%" - y="76%" - /> - <rect - data-testid="skeleton-chart-bar" - height="21%" - rx="0.6%" - width="3%" - x="54.5625%" - y="69%" - /> - <rect - data-testid="skeleton-chart-bar" - height="35%" - rx="0.6%" - width="3%" - x="66.6875%" - y="55%" - /> - <rect - data-testid="skeleton-chart-bar" - height="50%" - rx="0.6%" - width="3%" - x="78.8125%" - y="40%" - /> - <rect - data-testid="skeleton-chart-bar" - height="80%" - rx="0.6%" - width="3%" - x="90.9375%" - y="10%" - /> - - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="4.0625%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="16.1875%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="28.3125%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="40.4375%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="52.5625%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="64.6875%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="76.8125%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="88.9375%" - y="98%" - /> -</gl-skeleton-loader-stub> + <svg + class="gl-skeleton-loader" + preserveAspectRatio="xMidYMid meet" + version="1.1" + viewBox="0 0 400 130" + > + <rect + clip-path="url(#-idClip)" + height="130" + style="fill: url(#-idGradient);" + width="400" + x="0" + y="0" + /> + <defs> + <clippath + id="-idClip" + > + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.6%" + width="3%" + x="6.0625%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.6%" + width="3%" + x="18.1875%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.6%" + width="3%" + x="30.3125%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.6%" + width="3%" + x="42.4375%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.6%" + width="3%" + x="54.5625%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.6%" + width="3%" + x="66.6875%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.6%" + width="3%" + x="78.8125%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.6%" + width="3%" + x="90.9375%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="4.0625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="16.1875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="28.3125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="40.4375%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="52.5625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="64.6875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="76.8125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="88.9375%" + y="98%" + /> + </clippath> + <lineargradient + id="-idGradient" + > + <stop + class="primary-stop" + offset="0%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-2; 1" + /> + </stop> + <stop + class="secondary-stop" + offset="50%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1.5; 1.5" + /> + </stop> + <stop + class="primary-stop" + offset="100%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1; 2" + /> + </stop> + </lineargradient> + </defs> + </svg> +</div> `; diff --git a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js index 7facd02e596..bfc3aeb0303 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js +++ b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js @@ -1,11 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; describe('Resizable Skeleton Loader', () => { let wrapper; const createComponent = (propsData = {}) => { - wrapper = shallowMount(ChartSkeletonLoader, { + wrapper = mount(ChartSkeletonLoader, { propsData, }); }; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js index cafe53e6bb2..a823d04024d 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js @@ -5,8 +5,13 @@ describe('Build Custom Renderer Service', () => { it('should return an object with the default renderer functions when lacking arguments', () => { expect(buildCustomHTMLRenderer()).toEqual( expect.objectContaining({ - list: expect.any(Function), + htmlBlock: expect.any(Function), + htmlInline: expect.any(Function), + heading: expect.any(Function), + item: expect.any(Function), + paragraph: expect.any(Function), text: expect.any(Function), + softbreak: expect.any(Function), }), ); }); @@ -20,8 +25,6 @@ describe('Build Custom Renderer Service', () => { expect(buildCustomHTMLRenderer(customRenderers)).toEqual( expect.objectContaining({ html: expect.any(Function), - list: expect.any(Function), - text: expect.any(Function), }), ); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index a90d3528d60..fd745c21bb6 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -1,9 +1,10 @@ import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; +import { attributeDefinition } from './renderers/mock_data'; -describe('HTMLToMarkdownRenderer', () => { +describe('rich_content_editor/services/html_to_markdown_renderer', () => { let baseRenderer; let htmlToMarkdownRenderer; - const NODE = { nodeValue: 'mock_node' }; + let fakeNode; beforeEach(() => { baseRenderer = { @@ -12,14 +13,20 @@ describe('HTMLToMarkdownRenderer', () => { getSpaceControlled: jest.fn(input => `space controlled ${input}`), convert: jest.fn(), }; + + fakeNode = { nodeValue: 'mock_node', dataset: {} }; + }); + + afterEach(() => { + htmlToMarkdownRenderer = null; }); describe('TEXT_NODE visitor', () => { it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe( - `space controlled trimmed space collapsed ${NODE.nodeValue}`, + expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe( + `space controlled trimmed space collapsed ${fakeNode.nodeValue}`, ); }); }); @@ -43,8 +50,8 @@ describe('HTMLToMarkdownRenderer', () => { baseRenderer.convert.mockReturnValueOnce(list); - expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list); + expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list); }); }); @@ -62,10 +69,21 @@ describe('HTMLToMarkdownRenderer', () => { }); baseRenderer.convert.mockReturnValueOnce(listItem); - expect(htmlToMarkdownRenderer['UL LI'](NODE, listItem)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, listItem); + expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem); }, ); + + it('detects attribute definitions and attaches them to the list item', () => { + const listItem = '- list item'; + const result = `${listItem}\n${attributeDefinition}\n`; + + fakeNode.dataset.attributeDefinition = attributeDefinition; + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`); + + expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); + }); }); describe('OL LI visitor', () => { @@ -85,8 +103,8 @@ describe('HTMLToMarkdownRenderer', () => { }); baseRenderer.convert.mockReturnValueOnce(listItem); - expect(htmlToMarkdownRenderer['OL LI'](NODE, subContent)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, subContent); + expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent); }, ); }); @@ -105,8 +123,8 @@ describe('HTMLToMarkdownRenderer', () => { baseRenderer.convert.mockReturnValueOnce(input); - expect(htmlToMarkdownRenderer['STRONG, B'](NODE, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input); + expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); }, ); }); @@ -125,9 +143,50 @@ describe('HTMLToMarkdownRenderer', () => { baseRenderer.convert.mockReturnValueOnce(input); - expect(htmlToMarkdownRenderer['EM, I'](NODE, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input); + expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); }, ); }); + + describe('H1, H2, H3, H4, H5, H6 visitor', () => { + it('detects attribute definitions and attaches them to the heading', () => { + const heading = 'heading text'; + const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`; + + fakeNode.dataset.attributeDefinition = attributeDefinition; + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`); + + expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result); + }); + }); + + describe('PRE CODE', () => { + let node; + const subContent = 'sub content'; + const originalConverterResult = 'base result'; + + beforeEach(() => { + node = document.createElement('PRE'); + + node.innerText = 'reference definition content'; + node.dataset.sseReferenceDefinition = true; + + baseRenderer.convert.mockReturnValueOnce(originalConverterResult); + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + }); + + it('returns raw text when pre node has sse-reference-definitions class', () => { + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe( + `\n\n${node.innerText}\n\n`, + ); + }); + + it('returns base result when pre node does not have sse-reference-definitions class', () => { + delete node.dataset.sseReferenceDefinition; + + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js index 660c21281fd..5cf3961819e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js @@ -1,12 +1,6 @@ // Node spec helpers -export const buildMockTextNode = literal => { - return { - firstChild: null, - literal, - type: 'text', - }; -}; +export const buildMockTextNode = literal => ({ literal, type: 'text' }); export const normalTextNode = buildMockTextNode('This is just normal text.'); @@ -23,17 +17,20 @@ const buildMockUneditableOpenToken = type => { }; }; -const buildMockUneditableCloseToken = type => { - return { type: 'closeTag', tagName: type }; +const buildMockTextToken = content => { + return { + type: 'text', + tagName: null, + content, + }; }; -export const originToken = { - type: 'text', - tagName: null, - content: '{:.no_toc .hidden-md .hidden-lg}', -}; +const buildMockUneditableCloseToken = type => ({ type: 'closeTag', tagName: type }); + +export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}'); +const uneditableOpenToken = buildMockUneditableOpenToken('div'); +export const uneditableOpenTokens = [uneditableOpenToken, originToken]; export const uneditableCloseToken = buildMockUneditableCloseToken('div'); -export const uneditableOpenTokens = [buildMockUneditableOpenToken('div'), originToken]; export const uneditableCloseTokens = [originToken, uneditableCloseToken]; export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; @@ -41,6 +38,7 @@ export const originInlineToken = { type: 'text', content: '<i>Inline</i> content', }; + export const uneditableInlineTokens = [ buildMockUneditableOpenToken('a'), originInlineToken, @@ -48,11 +46,9 @@ export const uneditableInlineTokens = [ ]; export const uneditableBlockTokens = [ - buildMockUneditableOpenToken('div'), - { - type: 'text', - tagName: null, - content: '<div><h1>Some header</h1><p>Some paragraph</p></div>', - }, - buildMockUneditableCloseToken('div'), + uneditableOpenToken, + buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'), + uneditableCloseToken, ]; + +export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js new file mode 100644 index 00000000000..69fd9a67a21 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js @@ -0,0 +1,25 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition'; +import { attributeDefinition } from './mock_data'; + +describe('rich_content_editor/renderers/render_attribute_definition', () => { + describe('canRender', () => { + it.each` + input | result + ${{ literal: attributeDefinition }} | ${true} + ${{ literal: `FOO${attributeDefinition}` }} | ${false} + ${{ literal: `${attributeDefinition}BAR` }} | ${false} + ${{ literal: 'foobar' }} | ${false} + `('returns $result when input is $input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + it('returns an empty HTML comment', () => { + expect(renderer.render()).toEqual({ + type: 'html', + content: '<!-- sse-attribute-definition -->', + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js new file mode 100644 index 00000000000..76abc1ec3d8 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading'; +import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_heading', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index f4a06b91a10..b3d9576f38b 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -1,5 +1,4 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; -import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode } from './mock_data'; @@ -17,7 +16,7 @@ const identifierParagraphNode = buildMockParagraphNode( `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, ); -describe('Render Identifier Paragraph renderer', () => { +describe('rich_content_editor/renderers_render_identifier_paragraph', () => { describe('canRender', () => { it.each` node | paragraph | target @@ -37,8 +36,49 @@ describe('Render Identifier Paragraph renderer', () => { }); describe('render', () => { - it('should delegate rendering to the renderUneditableBranch util', () => { - expect(renderer.render).toBe(renderUneditableBranch); + let context; + let result; + + beforeEach(() => { + const node = { + firstChild: { + type: 'text', + literal: '[Some text]: https://link.com', + next: { + type: 'linebreak', + next: { + type: 'text', + literal: '[identifier]: http://example1.com "title"', + }, + }, + }, + }; + context = { skipChildren: jest.fn() }; + result = renderer.render(node, context); + }); + + it('renders the reference definitions as a code block', () => { + expect(result).toEqual([ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { + 'data-sse-reference-definition': true, + }, + }, + { type: 'openTag', tagName: 'code' }, + { + type: 'text', + content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', + }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]); + }); + + it('skips the reference definition node children from rendering', () => { + expect(context.skipChildren).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js deleted file mode 100644 index 7d427108ba6..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list'; -import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -import { buildMockTextNode } from './mock_data'; - -const buildMockListNode = literal => { - return { - firstChild: { - firstChild: { - firstChild: buildMockTextNode(literal), - type: 'paragraph', - }, - type: 'item', - }, - type: 'list', - }; -}; - -const normalListNode = buildMockListNode('Just another bullet point'); -const kramdownListNode = buildMockListNode('TOC'); - -describe('Render Kramdown List renderer', () => { - describe('canRender', () => { - it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => { - expect(renderer.canRender(kramdownListNode)).toBe(true); - }); - - it('should return false when the argument is a normal ordered/unordered list', () => { - expect(renderer.canRender(normalListNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should delegate rendering to the renderUneditableBranch util', () => { - expect(renderer.render).toBe(renderUneditableBranch); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js deleted file mode 100644 index 1d2d152ffc3..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text'; -import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -import { buildMockTextNode, normalTextNode } from './mock_data'; - -const kramdownTextNode = buildMockTextNode('{:toc}'); - -describe('Render Kramdown Text renderer', () => { - describe('canRender', () => { - it('should return true when the argument `literal` has kramdown syntax', () => { - expect(renderer.canRender(kramdownTextNode)).toBe(true); - }); - - it('should return false when the argument `literal` lacks kramdown syntax', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should delegate rendering to the renderUneditableLeaf util', () => { - expect(renderer.render).toBe(renderUneditableLeaf); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js new file mode 100644 index 00000000000..c1ab700535b --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item'; +import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_list_item', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js index 92435b3e4e3..774f830f421 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js @@ -1,6 +1,8 @@ import { renderUneditableLeaf, renderUneditableBranch, + renderWithAttributeDefinitions, + willAlwaysRender, } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { @@ -8,9 +10,9 @@ import { buildUneditableOpenTokens, } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import { originToken, uneditableCloseToken } from './mock_data'; +import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; -describe('Render utils', () => { +describe('rich_content_editor/renderers/render_utils', () => { describe('renderUneditableLeaf', () => { it('should return uneditable block tokens around an origin token', () => { const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; @@ -41,4 +43,68 @@ describe('Render utils', () => { expect(result).toStrictEqual(uneditableCloseToken); }); }); + + describe('willAlwaysRender', () => { + it('always returns true', () => { + expect(willAlwaysRender()).toBe(true); + }); + }); + + describe('renderWithAttributeDefinitions', () => { + let openTagToken; + let closeTagToken; + let node; + const attributes = { + 'data-attribute-definition': attributeDefinition, + }; + + beforeEach(() => { + openTagToken = { type: 'openTag' }; + closeTagToken = { type: 'closeTag' }; + node = { + next: { + firstChild: { + literal: attributeDefinition, + }, + }, + }; + }); + + describe('when token type is openTag', () => { + it('attaches attributes when attributes exist in the node’s next sibling', () => { + const context = { origin: () => openTagToken }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + + it('attaches attributes when attributes exist in the node’s children', () => { + const context = { origin: () => openTagToken }; + node = { + firstChild: { + firstChild: { + next: { + next: { + literal: attributeDefinition, + }, + }, + }, + }, + }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + }); + + it('does not attach attributes when token type is "closeTag"', () => { + const context = { origin: () => closeTagToken }; + + expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index edec3b138b3..c2091a681f2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -14,6 +14,7 @@ const createComponent = headerTitle => { }; describe('DropdownCreateLabelComponent', () => { + const colorsCount = Object.keys(mockSuggestedColors).length; let vm; beforeEach(() => { @@ -27,7 +28,7 @@ describe('DropdownCreateLabelComponent', () => { describe('created', () => { it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => { - expect(vm.suggestedColors.length).toBe(mockSuggestedColors.length); + expect(vm.suggestedColors.length).toBe(colorsCount); }); }); @@ -37,12 +38,10 @@ describe('DropdownCreateLabelComponent', () => { }); it('renders `Go back` button on component header', () => { - const backButtonEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-back', - ); + const backButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-back'); expect(backButtonEl).not.toBe(null); - expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null); + expect(backButtonEl.querySelector('[data-testid="arrow-left-icon"]')).not.toBe(null); }); it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => { @@ -61,12 +60,9 @@ describe('DropdownCreateLabelComponent', () => { }); it('renders `Close` button on component header', () => { - const closeButtonEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-close', - ); + const closeButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-close'); expect(closeButtonEl).not.toBe(null); - expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null); }); it('renders `Name new label` input element', () => { @@ -78,11 +74,11 @@ describe('DropdownCreateLabelComponent', () => { const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown'); expect(colorsListContainerEl).not.toBe(null); - expect(colorsListContainerEl.querySelectorAll('a').length).toBe(mockSuggestedColors.length); + expect(colorsListContainerEl.querySelectorAll('a').length).toBe(colorsCount); const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0]; - expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0]); + expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0].colorCode); expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 51, 204);'); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js index 2e721d75b40..0b9a7262e41 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js @@ -33,7 +33,7 @@ describe('DropdownHeaderComponent', () => { ); expect(closeBtnEl).not.toBeNull(); - expect(closeBtnEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBeNull(); + expect(closeBtnEl.querySelector('.dropdown-menu-close-icon')).not.toBeNull(); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index e09f0006359..7847e0ee71d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -87,7 +87,7 @@ describe('DropdownValueCollapsedComponent', () => { }); it('renders tags icon element', () => { - expect(vm.$el.querySelector('.fa-tags')).not.toBeNull(); + expect(vm.$el.querySelector('[data-testid="labels-icon"]')).not.toBeNull(); }); it('renders labels count', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js index 6564c012e67..648ba84fe8f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js @@ -15,29 +15,29 @@ export const mockLabels = [ }, ]; -export const mockSuggestedColors = [ - '#0033CC', - '#428BCA', - '#44AD8E', - '#A8D695', - '#5CB85C', - '#69D100', - '#004E00', - '#34495E', - '#7F8C8D', - '#A295D6', - '#5843AD', - '#8E44AD', - '#FFECDB', - '#AD4363', - '#D10069', - '#CC0033', - '#FF0000', - '#D9534F', - '#D1D100', - '#F0AD4E', - '#AD8D43', -]; +export const mockSuggestedColors = { + '#0033CC': 'UA blue', + '#428BCA': 'Moderate blue', + '#44AD8E': 'Lime green', + '#A8D695': 'Feijoa', + '#5CB85C': 'Slightly desaturated green', + '#69D100': 'Bright green', + '#004E00': 'Very dark lime green', + '#34495E': 'Very dark desaturated blue', + '#7F8C8D': 'Dark grayish cyan', + '#A295D6': 'Slightly desaturated blue', + '#5843AD': 'Dark moderate blue', + '#8E44AD': 'Dark moderate violet', + '#FFECDB': 'Very pale orange', + '#AD4363': 'Dark moderate pink', + '#D10069': 'Strong pink', + '#CC0033': 'Strong red', + '#FF0000': 'Pure red', + '#D9534F': 'Soft red', + '#D1D100': 'Strong yellow', + '#F0AD4E': 'Soft orange', + '#AD8D43': 'Dark moderate orange', +}; export const mockConfig = { showCreate: true, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js index cb758797c63..951f706421f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js @@ -62,7 +62,7 @@ describe('DropdownButton', () => { describe('template', () => { it('renders component container element', () => { - expect(wrapper.is('gl-button-stub')).toBe(true); + expect(wrapper.find(GlButton).element).toBe(wrapper.element); }); it('renders default button text element', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index a1e0db4d29e..8c17a974b39 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -65,6 +65,33 @@ describe('LabelsSelectRoot', () => { ]), ); }); + + it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { + wrapper = createComponent({ + ...mockConfig, + variant: 'embedded', + }); + + jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); + + wrapper.vm.handleVuexActionDispatch( + { type: 'toggleDropdownContents' }, + { + showDropdownButton: false, + showDropdownContents: false, + labels: [{ id: 1 }, { id: 2, set: true }], + }, + ); + + expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( + expect.arrayContaining([ + { + id: 2, + set: true, + }, + ]), + ); + }); }); describe('handleDropdownClose', () => { @@ -123,11 +150,10 @@ describe('LabelsSelectRoot', () => { expect(wrapper.find(DropdownTitle).exists()).toBe(true); }); - it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => { + it('renders `dropdown-value` component', () => { const wrapperDropdownValue = createComponent(mockConfig, { default: 'None', }); - wrapperDropdownValue.vm.$store.state.showDropdownButton = false; return wrapperDropdownValue.vm.$nextTick(() => { const valueComp = wrapperDropdownValue.find(DropdownValue); 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 c742220ba8a..bfb8e263d81 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,6 +259,21 @@ 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 8081806e314..3414eec8a63 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,6 +152,19 @@ 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/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js index ef3ae088eec..058dfcdbde2 100644 --- a/spec/frontend/vue_shared/components/table_pagination_spec.js +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -34,7 +34,7 @@ describe('Pagination component', () => { change: spy, }); - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); it('renders if there is a next page', () => { @@ -50,7 +50,7 @@ describe('Pagination component', () => { change: spy, }); - expect(wrapper.isEmpty()).toBe(false); + expect(wrapper.find(GlPagination).exists()).toBe(true); }); it('renders if there is a prev page', () => { @@ -66,7 +66,7 @@ describe('Pagination component', () => { change: spy, }); - expect(wrapper.isEmpty()).toBe(false); + expect(wrapper.find(GlPagination).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js new file mode 100644 index 00000000000..482b5de11f6 --- /dev/null +++ b/spec/frontend/vue_shared/components/todo_button_spec.js @@ -0,0 +1,48 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import TodoButton from '~/vue_shared/components/todo_button.vue'; + +describe('Todo Button', () => { + let wrapper; + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TodoButton, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders GlButton', () => { + createComponent(); + + expect(wrapper.find(GlButton).exists()).toBe(true); + }); + + it('emits click event when clicked', () => { + createComponent({}, mount); + wrapper.find(GlButton).trigger('click'); + + expect(wrapper.emitted().click).toBeTruthy(); + }); + + it.each` + label | isTodo + ${'Mark as done'} | ${true} + ${'Add a To-Do'} | ${false} + `('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => { + createComponent({ isTodo }); + + expect(wrapper.find(GlButton).text()).toBe(label); + }); + + it('binds additional props to GlButton', () => { + createComponent({ loading: true }); + + expect(wrapper.find(GlButton).props('loading')).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index 902e83da7be..84e7a6a162e 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -38,10 +38,6 @@ describe('User Avatar Link Component', () => { wrapper = null; }); - it('should return a defined Vue component', () => { - expect(wrapper.isVueInstance()).toBe(true); - }); - it('should have user-avatar-image registered as child component', () => { expect(wrapper.vm.$options.components.userAvatarImage).toBeDefined(); }); 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 a4ff6ac0c16..b43bb6b10e0 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 @@ -1,7 +1,6 @@ -import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const DEFAULT_PROPS = { user: { @@ -74,7 +73,7 @@ describe('User Popover Component', () => { }); it('shows icon for location', () => { - const iconEl = wrapper.find(Icon); + const iconEl = wrapper.find(GlIcon); expect(iconEl.props('name')).toEqual('location'); }); @@ -139,9 +138,9 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.findAll(Icon).filter(icon => icon.props('name') === 'profile').length).toEqual( - 1, - ); + expect( + wrapper.findAll(GlIcon).filter(icon => icon.props('name') === 'profile').length, + ).toEqual(1); }); it('shows icon for work information', () => { @@ -152,7 +151,9 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.findAll(Icon).filter(icon => icon.props('name') === 'work').length).toEqual(1); + expect(wrapper.findAll(GlIcon).filter(icon => icon.props('name') === 'work').length).toEqual( + 1, + ); }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js new file mode 100644 index 00000000000..57f511903d9 --- /dev/null +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +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_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/'; +const TEST_GITPOD_URL = 'https://gitpod.test/'; + +const ACTION_WEB_IDE = { + href: TEST_WEB_IDE_URL, + key: 'webide', + secondaryText: 'Quickly and easily edit multiple files in your project.', + tooltip: '', + text: 'Web IDE', + attrs: { + 'data-qa-selector': 'web_ide_button', + }, +}; +const ACTION_WEB_IDE_FORK = { + ...ACTION_WEB_IDE, + href: '#modal-confirm-fork', + handle: expect.any(Function), +}; +const ACTION_GITPOD = { + href: TEST_GITPOD_URL, + key: 'gitpod', + secondaryText: 'Launch a ready-to-code development environment for your project.', + tooltip: 'Launch a ready-to-code development environment for your project.', + text: 'Gitpod', + attrs: { + 'data-qa-selector': 'gitpod_button', + }, +}; +const ACTION_GITPOD_ENABLE = { + ...ACTION_GITPOD, + href: '#modal-enable-gitpod', + handle: expect.any(Function), +}; + +describe('Web IDE link component', () => { + let wrapper; + + function createComponent(props) { + wrapper = shallowMount(WebIdeLink, { + propsData: { + webIdeUrl: TEST_WEB_IDE_URL, + gitpodUrl: TEST_GITPOD_URL, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + 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 }) => { + createComponent(props); + + expect(findActionsButton().props('actions')).toEqual(expectedActions); + }); + + describe('with multiple actions', () => { + beforeEach(() => { + createComponent({ showWebIdeButton: true, showGitpodButton: true, gitpodEnabled: true }); + }); + + it('selected Web IDE by default', () => { + expect(findActionsButton().props()).toMatchObject({ + actions: [ACTION_WEB_IDE, ACTION_GITPOD], + selectedKey: ACTION_WEB_IDE.key, + }); + }); + + it('should set selection with local storage value', async () => { + expect(findActionsButton().props('selectedKey')).toBe(ACTION_WEB_IDE.key); + + findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); + + await wrapper.vm.$nextTick(); + + expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key); + }); + + it('should update local storage when selection changes', async () => { + expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key); + + findActionsButton().vm.$emit('select', ACTION_GITPOD.key); + + await wrapper.vm.$nextTick(); + + expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key); + expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key); + }); + }); +}); diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js index a349aad9f1c..59d05f68fdd 100644 --- a/spec/frontend/whats_new/components/app_spec.js +++ b/spec/frontend/whats_new/components/app_spec.js @@ -11,9 +11,11 @@ describe('App', () => { let store; let actions; let state; + let propsData = { features: '[ {"title":"Whats New Drawer"} ]' }; - beforeEach(() => { + const buildWrapper = () => { actions = { + openDrawer: jest.fn(), closeDrawer: jest.fn(), }; @@ -29,7 +31,12 @@ describe('App', () => { wrapper = mount(App, { localVue, store, + propsData, }); + }; + + beforeEach(() => { + buildWrapper(); }); afterEach(() => { @@ -42,6 +49,10 @@ describe('App', () => { expect(getDrawer().exists()).toBe(true); }); + it('dispatches openDrawer when mounted', () => { + expect(actions.openDrawer).toHaveBeenCalled(); + }); + it('dispatches closeDrawer when clicking close', () => { getDrawer().vm.$emit('close'); expect(actions.closeDrawer).toHaveBeenCalled(); @@ -54,4 +65,15 @@ describe('App', () => { expect(getDrawer().props('open')).toBe(openState); }); + + it('renders features when provided as props', () => { + expect(wrapper.find('h5').text()).toBe('Whats New Drawer'); + }); + + it('handles bad json argument gracefully', () => { + propsData = { features: 'this is not json' }; + buildWrapper(); + + expect(getDrawer().exists()).toBe(true); + }); }); diff --git a/spec/frontend/whats_new/components/trigger_spec.js b/spec/frontend/whats_new/components/trigger_spec.js deleted file mode 100644 index 7961957e077..00000000000 --- a/spec/frontend/whats_new/components/trigger_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { createLocalVue, mount } from '@vue/test-utils'; -import Vuex from 'vuex'; -import { GlButton } from '@gitlab/ui'; -import Trigger from '~/whats_new/components/trigger.vue'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('Trigger', () => { - let wrapper; - let store; - let actions; - let state; - - beforeEach(() => { - actions = { - openDrawer: jest.fn(), - }; - - state = { - open: true, - }; - - store = new Vuex.Store({ - actions, - state, - }); - - wrapper = mount(Trigger, { - localVue, - store, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('dispatches openDrawer when clicking close', () => { - wrapper.find(GlButton).vm.$emit('click'); - expect(actions.openDrawer).toHaveBeenCalled(); - }); -}); |