diff options
Diffstat (limited to 'spec/frontend')
644 files changed, 20717 insertions, 7336 deletions
diff --git a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js index 726ed0fa030..9fee8e18d26 100644 --- a/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js +++ b/spec/frontend/__mocks__/@toast-ui/vue-editor/index.js @@ -17,6 +17,17 @@ export const Editor = { type: String, }, }, + created() { + const mockEditorApi = { + eventManager: { + addEventType: jest.fn(), + listen: jest.fn(), + removeEventHandler: jest.fn(), + }, + }; + + this.$emit('load', mockEditorApi); + }, render(h) { return h('div'); }, diff --git a/spec/frontend/__mocks__/monaco-editor/index.js b/spec/frontend/__mocks__/monaco-editor/index.js index b9602d69b74..18b7df32f9b 100644 --- a/spec/frontend/__mocks__/monaco-editor/index.js +++ b/spec/frontend/__mocks__/monaco-editor/index.js @@ -8,11 +8,11 @@ import 'monaco-editor/esm/vs/language/css/monaco.contribution'; import 'monaco-editor/esm/vs/language/json/monaco.contribution'; import 'monaco-editor/esm/vs/language/html/monaco.contribution'; import 'monaco-editor/esm/vs/basic-languages/monaco.contribution'; -import 'monaco-yaml/esm/monaco.contribution'; +import 'monaco-yaml/lib/esm/monaco.contribution'; // This language starts trying to spin up web workers which obviously breaks in Jest environment jest.mock('monaco-editor/esm/vs/language/typescript/tsMode'); -jest.mock('monaco-yaml/esm/yamlMode'); +jest.mock('monaco-yaml/lib/esm/yamlMode'); export * from 'monaco-editor/esm/vs/editor/editor.api'; export default global.monaco; diff --git a/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap new file mode 100644 index 00000000000..5fad0d07f97 --- /dev/null +++ b/spec/frontend/add_context_commits_modal/components/__snapshots__/add_context_commits_modal_spec.js.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`AddContextCommitsModal renders modal with 2 tabs 1`] = ` +<gl-modal-stub + body-class="add-review-item pt-0" + cancel-variant="light" + modalclass="" + modalid="add-review-item" + ok-disabled="true" + ok-title="Save changes" + scrollable="true" + size="md" + title="Add or remove previously merged commits" + titletag="h4" +> + <gl-tabs-stub + contentclass="pt-0" + theme="indigo" + value="0" + > + <gl-tab-stub> + + <div + class="mt-2" + > + <gl-search-box-by-type-stub + clearbuttontitle="Clear" + placeholder="Search by commit title or SHA" + value="" + /> + + <review-tab-container-stub + commits="" + emptylisttext="Your search didn't match any commits. Try a different query." + loadingfailedtext="Unable to load commits. Try again later." + /> + </div> + </gl-tab-stub> + + <gl-tab-stub> + + <review-tab-container-stub + commits="" + emptylisttext="Commits you select appear here. Go to the first tab and select commits to add to this merge request." + loadingfailedtext="Unable to load commits. Try again later." + /> + </gl-tab-stub> + </gl-tabs-stub> +</gl-modal-stub> +`; 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 new file mode 100644 index 00000000000..6904e34db5d --- /dev/null +++ b/spec/frontend/add_context_commits_modal/components/add_context_commits_modal_spec.js @@ -0,0 +1,174 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlModal, GlSearchBoxByType } from '@gitlab/ui'; +import AddReviewItemsModal from '~/add_context_commits_modal/components/add_context_commits_modal_wrapper.vue'; +import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; + +import defaultState from '~/add_context_commits_modal/store/state'; +import mutations from '~/add_context_commits_modal/store/mutations'; +import * as actions from '~/add_context_commits_modal/store/actions'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('AddContextCommitsModal', () => { + let wrapper; + let store; + const createContextCommits = jest.fn(); + const removeContextCommits = jest.fn(); + const resetModalState = jest.fn(); + const searchCommits = jest.fn(); + const { commit } = getDiffWithCommit(); + + const createWrapper = (props = {}) => { + store = new Vuex.Store({ + mutations, + state: { + ...defaultState(), + }, + actions: { + ...actions, + searchCommits, + createContextCommits, + removeContextCommits, + resetModalState, + }, + }); + + wrapper = shallowMount(AddReviewItemsModal, { + localVue, + store, + propsData: { + contextCommitsPath: '', + targetBranch: 'master', + mergeRequestIid: 1, + projectId: 1, + ...props, + }, + }); + return wrapper; + }; + + const findModal = () => wrapper.find(GlModal); + const findSearch = () => wrapper.find(GlSearchBoxByType); + + beforeEach(() => { + wrapper = createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders modal with 2 tabs', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('an ok button labeled "Save changes"', () => { + expect(findModal().attributes('ok-title')).toEqual('Save changes'); + }); + + describe('when in first tab, renders a modal with', () => { + it('renders the search box component', () => { + expect(findSearch().exists()).toBe(true); + }); + + it('when user starts entering text in search box, it calls action "searchCommits" after waiting for 500s', () => { + const searchText = 'abcd'; + findSearch().vm.$emit('input', searchText); + expect(searchCommits).not.toBeCalled(); + jest.advanceTimersByTime(500); + expect(searchCommits).toHaveBeenCalledWith(expect.anything(), searchText, undefined); + }); + + it('disabled ok button when no row is selected', () => { + expect(findModal().attributes('ok-disabled')).toBe('true'); + }); + + it('enabled ok button when atleast one row is selected', () => { + wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; + return wrapper.vm.$nextTick().then(() => { + expect(findModal().attributes('ok-disabled')).toBeFalsy(); + }); + }); + }); + + describe('when in second tab, renders a modal with', () => { + beforeEach(() => { + wrapper.vm.$store.state.tabIndex = 1; + }); + it('a disabled ok button when no row is selected', () => { + expect(findModal().attributes('ok-disabled')).toBe('true'); + }); + + it('an enabled ok button when atleast one row is selected', () => { + wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; + return wrapper.vm.$nextTick().then(() => { + expect(findModal().attributes('ok-disabled')).toBeFalsy(); + }); + }); + + it('a disabled ok button in first tab, when row is selected in second tab', () => { + createWrapper({ selectedContextCommits: [commit] }); + expect(wrapper.find(GlModal).attributes('ok-disabled')).toBe('true'); + }); + }); + + describe('has an ok button when clicked calls action', () => { + it('"createContextCommits" when only new commits to be added ', () => { + 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, + ); + }); + }); + 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); + }); + }); + it('"createContextCommits" and "removeContextCommits" when new commits are to be added and old commits are to be removed', () => { + wrapper.vm.$store.state.selectedCommits = [{ ...commit, isSelected: true }]; + 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); + }); + }); + }); + + describe('has a cancel button when clicked', () => { + it('does not call "createContextCommits" or "removeContextCommits"', () => { + findModal().vm.$emit('cancel'); + expect(createContextCommits).not.toHaveBeenCalled(); + expect(removeContextCommits).not.toHaveBeenCalled(); + }); + it('"resetModalState" to reset all the modal state', () => { + findModal().vm.$emit('cancel'); + expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + }); + }); + + describe('when model is closed by clicking the "X" button or by pressing "ESC" key', () => { + it('does not call "createContextCommits" or "removeContextCommits"', () => { + findModal().vm.$emit('close'); + expect(createContextCommits).not.toHaveBeenCalled(); + expect(removeContextCommits).not.toHaveBeenCalled(); + }); + it('"resetModalState" to reset all the modal state', () => { + findModal().vm.$emit('close'); + expect(resetModalState).toHaveBeenCalledWith(expect.anything(), undefined, undefined); + }); + }); +}); diff --git a/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js new file mode 100644 index 00000000000..4e65713a680 --- /dev/null +++ b/spec/frontend/add_context_commits_modal/components/review_tab_container_spec.js @@ -0,0 +1,51 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; +import ReviewTabContainer from '~/add_context_commits_modal/components/review_tab_container.vue'; +import CommitItem from '~/diffs/components/commit_item.vue'; +import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; + +describe('ReviewTabContainer', () => { + let wrapper; + const { commit } = getDiffWithCommit(); + + const createWrapper = (props = {}) => { + wrapper = shallowMount(ReviewTabContainer, { + propsData: { + tab: 'commits', + isLoading: false, + loadingError: false, + loadingFailedText: 'Failed to load commits', + commits: [], + selectedRow: [], + ...props, + }, + }); + }; + + beforeEach(() => { + createWrapper(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('shows loading icon when commits are being loaded', () => { + createWrapper({ isLoading: true }); + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + + it('shows loading error text when API call fails', () => { + createWrapper({ loadingError: true }); + expect(wrapper.text()).toContain('Failed to load commits'); + }); + + it('shows "No commits present here" when commits are not present', () => { + expect(wrapper.text()).toContain('No commits present here'); + }); + + it('renders all passed commits as list', () => { + createWrapper({ commits: [commit] }); + expect(wrapper.findAll(CommitItem).length).toBe(1); + }); +}); diff --git a/spec/frontend/add_context_commits_modal/store/actions_spec.js b/spec/frontend/add_context_commits_modal/store/actions_spec.js new file mode 100644 index 00000000000..24948dd6073 --- /dev/null +++ b/spec/frontend/add_context_commits_modal/store/actions_spec.js @@ -0,0 +1,239 @@ +import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import { + setBaseConfig, + setTabIndex, + setCommits, + createContextCommits, + fetchContextCommits, + setContextCommits, + removeContextCommits, + setSelectedCommits, + setSearchText, + setToRemoveCommits, + resetModalState, +} from '~/add_context_commits_modal/store/actions'; +import * as types from '~/add_context_commits_modal/store/mutation_types'; +import testAction from '../../helpers/vuex_action_helper'; + +describe('AddContextCommitsModalStoreActions', () => { + const contextCommitEndpoint = + '/api/v4/projects/gitlab-org%2fgitlab/merge_requests/1/context_commits'; + const mergeRequestIid = 1; + const projectId = 1; + const projectPath = 'gitlab-org/gitlab'; + const contextCommitsPath = `${TEST_HOST}/gitlab-org/gitlab/-/merge_requests/1/context_commits.json`; + const dummyCommit = { + id: 1, + title: 'dummy commit', + short_id: 'abcdef', + committed_date: '2020-06-12', + }; + gon.api_version = 'v4'; + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setBaseConfig', () => { + it('commits SET_BASE_CONFIG', done => { + const options = { contextCommitsPath, mergeRequestIid, projectId }; + testAction( + setBaseConfig, + options, + { + contextCommitsPath: '', + mergeRequestIid, + projectId, + }, + [ + { + type: types.SET_BASE_CONFIG, + payload: options, + }, + ], + [], + done, + ); + }); + }); + + describe('setTabIndex', () => { + it('commits SET_TABINDEX', done => { + testAction( + setTabIndex, + { tabIndex: 1 }, + { tabIndex: 0 }, + [{ type: types.SET_TABINDEX, payload: { tabIndex: 1 } }], + [], + done, + ); + }); + }); + + describe('setCommits', () => { + it('commits SET_COMMITS', done => { + testAction( + setCommits, + { commits: [], silentAddition: false }, + { isLoadingCommits: false, commits: [] }, + [{ type: types.SET_COMMITS, payload: [] }], + [], + done, + ); + }); + + it('commits SET_COMMITS_SILENT', done => { + testAction( + setCommits, + { commits: [], silentAddition: true }, + { isLoadingCommits: true, commits: [] }, + [{ type: types.SET_COMMITS_SILENT, payload: [] }], + [], + done, + ); + }); + }); + + describe('createContextCommits', () => { + it('calls API to create context commits', done => { + mock.onPost(contextCommitEndpoint).reply(200, {}); + + testAction(createContextCommits, { commits: [] }, {}, [], [], done); + + createContextCommits( + { state: { projectId, mergeRequestIid }, commit: () => null }, + { commits: [] }, + ) + .then(() => { + done(); + }) + .catch(done.fail); + }); + }); + + describe('fetchContextCommits', () => { + beforeEach(() => { + mock + .onGet( + `/api/${gon.api_version}/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits`, + ) + .reply(200, [dummyCommit]); + }); + it('commits FETCH_CONTEXT_COMMITS', done => { + const contextCommit = { ...dummyCommit, isSelected: true }; + testAction( + fetchContextCommits, + null, + { + mergeRequestIid, + projectId: projectPath, + isLoadingContextCommits: false, + contextCommitsLoadingError: false, + commits: [], + }, + [{ type: types.FETCH_CONTEXT_COMMITS }], + [ + { type: 'setContextCommits', payload: [contextCommit] }, + { type: 'setCommits', payload: { commits: [contextCommit], silentAddition: true } }, + { type: 'setSelectedCommits', payload: [contextCommit] }, + ], + done, + ); + }); + }); + + describe('setContextCommits', () => { + it('commits SET_CONTEXT_COMMITS', done => { + testAction( + setContextCommits, + { data: [] }, + { contextCommits: [], isLoadingContextCommits: false }, + [{ type: types.SET_CONTEXT_COMMITS, payload: { data: [] } }], + [], + done, + ); + }); + }); + + describe('removeContextCommits', () => { + beforeEach(() => { + mock + .onDelete('/api/v4/projects/gitlab-org%2Fgitlab/merge_requests/1/context_commits') + .reply(204); + }); + it('calls API to remove context commits', done => { + testAction( + removeContextCommits, + { forceReload: false }, + { mergeRequestIid, projectId, toRemoveCommits: [] }, + [], + [], + done, + ); + }); + }); + + describe('setSelectedCommits', () => { + it('commits SET_SELECTED_COMMITS', done => { + testAction( + setSelectedCommits, + [dummyCommit], + { selectedCommits: [] }, + [{ type: types.SET_SELECTED_COMMITS, payload: [dummyCommit] }], + [], + done, + ); + }); + }); + + describe('setSearchText', () => { + it('commits SET_SEARCH_TEXT', done => { + const searchText = 'Dummy Text'; + testAction( + setSearchText, + searchText, + { searchText: '' }, + [{ type: types.SET_SEARCH_TEXT, payload: searchText }], + [], + done, + ); + }); + }); + + describe('setToRemoveCommits', () => { + it('commits SET_TO_REMOVE_COMMITS', done => { + const commitId = 'abcde'; + + testAction( + setToRemoveCommits, + [commitId], + { toRemoveCommits: [] }, + [{ type: types.SET_TO_REMOVE_COMMITS, payload: [commitId] }], + [], + done, + ); + }); + }); + + describe('resetModalState', () => { + it('commits RESET_MODAL_STATE', done => { + const commitId = 'abcde'; + + testAction( + resetModalState, + null, + { toRemoveCommits: [commitId] }, + [{ type: types.RESET_MODAL_STATE }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/add_context_commits_modal/store/mutations_spec.js b/spec/frontend/add_context_commits_modal/store/mutations_spec.js new file mode 100644 index 00000000000..22f82570ab1 --- /dev/null +++ b/spec/frontend/add_context_commits_modal/store/mutations_spec.js @@ -0,0 +1,156 @@ +import { TEST_HOST } from 'helpers/test_constants'; +import mutations from '~/add_context_commits_modal/store/mutations'; +import * as types from '~/add_context_commits_modal/store/mutation_types'; +import getDiffWithCommit from '../../diffs/mock_data/diff_with_commit'; + +describe('AddContextCommitsModalStoreMutations', () => { + const { commit } = getDiffWithCommit(); + describe('SET_BASE_CONFIG', () => { + it('should set contextCommitsPath, mergeRequestIid and projectId', () => { + const state = {}; + const contextCommitsPath = `${TEST_HOST}/gitlab-org/gitlab/-/merge_requests/1/context_commits.json`; + const mergeRequestIid = 1; + const projectId = 1; + + mutations[types.SET_BASE_CONFIG](state, { contextCommitsPath, mergeRequestIid, projectId }); + + expect(state.contextCommitsPath).toEqual(contextCommitsPath); + expect(state.mergeRequestIid).toEqual(mergeRequestIid); + expect(state.projectId).toEqual(projectId); + }); + }); + + describe('SET_TABINDEX', () => { + it('sets tabIndex to specific index', () => { + const state = { tabIndex: 0 }; + + mutations[types.SET_TABINDEX](state, 1); + + expect(state.tabIndex).toBe(1); + }); + }); + + describe('FETCH_COMMITS', () => { + it('sets isLoadingCommits to true', () => { + const state = { isLoadingCommits: false }; + + mutations[types.FETCH_COMMITS](state); + + expect(state.isLoadingCommits).toBe(true); + }); + }); + + describe('SET_COMMITS', () => { + it('sets commits to passed data and stop loading', () => { + const state = { commits: [], isLoadingCommits: true }; + + mutations[types.SET_COMMITS](state, [commit]); + + expect(state.commits).toStrictEqual([commit]); + expect(state.isLoadingCommits).toBe(false); + }); + }); + + describe('SET_COMMITS_SILENT', () => { + it('sets commits to passed data and loading continues', () => { + const state = { commits: [], isLoadingCommits: true }; + + mutations[types.SET_COMMITS_SILENT](state, [commit]); + + expect(state.commits).toStrictEqual([commit]); + expect(state.isLoadingCommits).toBe(true); + }); + }); + + describe('FETCH_COMMITS_ERROR', () => { + it('sets commitsLoadingError to true', () => { + const state = { commitsLoadingError: false }; + + mutations[types.FETCH_COMMITS_ERROR](state); + + expect(state.commitsLoadingError).toBe(true); + }); + }); + + describe('FETCH_CONTEXT_COMMITS', () => { + it('sets isLoadingContextCommits to true', () => { + const state = { isLoadingContextCommits: false }; + + mutations[types.FETCH_CONTEXT_COMMITS](state); + + expect(state.isLoadingContextCommits).toBe(true); + }); + }); + + describe('SET_CONTEXT_COMMITS', () => { + it('sets contextCommit to passed data and stop loading', () => { + const state = { contextCommits: [], isLoadingContextCommits: true }; + + mutations[types.SET_CONTEXT_COMMITS](state, [commit]); + + expect(state.contextCommits).toStrictEqual([commit]); + expect(state.isLoadingContextCommits).toBe(false); + }); + }); + + describe('FETCH_CONTEXT_COMMITS_ERROR', () => { + it('sets contextCommitsLoadingError to true', () => { + const state = { contextCommitsLoadingError: false }; + + mutations[types.FETCH_CONTEXT_COMMITS_ERROR](state); + + expect(state.contextCommitsLoadingError).toBe(true); + }); + }); + + describe('SET_SELECTED_COMMITS', () => { + it('sets selectedCommits to specified value', () => { + const state = { selectedCommits: [] }; + + mutations[types.SET_SELECTED_COMMITS](state, [commit]); + + expect(state.selectedCommits).toStrictEqual([commit]); + }); + }); + + describe('SET_SEARCH_TEXT', () => { + it('sets searchText to specified value', () => { + const searchText = 'Test'; + const state = { searchText: '' }; + + mutations[types.SET_SEARCH_TEXT](state, searchText); + + expect(state.searchText).toBe(searchText); + }); + }); + + describe('SET_TO_REMOVE_COMMITS', () => { + it('sets searchText to specified value', () => { + const state = { toRemoveCommits: [] }; + + mutations[types.SET_TO_REMOVE_COMMITS](state, [commit.short_id]); + + expect(state.toRemoveCommits).toStrictEqual([commit.short_id]); + }); + }); + + describe('RESET_MODAL_STATE', () => { + it('sets searchText to specified value', () => { + const state = { + commits: [commit], + contextCommits: [commit], + selectedCommits: [commit], + toRemoveCommits: [commit.short_id], + searchText: 'Test', + }; + + mutations[types.RESET_MODAL_STATE](state); + + expect(state.commits).toStrictEqual([]); + expect(state.contextCommits).toStrictEqual([]); + expect(state.selectedCommits).toStrictEqual([]); + expect(state.toRemoveCommits).toStrictEqual([]); + expect(state.searchText).toBe(''); + }); + }); +}); diff --git a/spec/frontend/alert_management/components/alert_management_detail_spec.js b/spec/frontend/alert_management/components/alert_details_spec.js index daa730d3b9f..2c4ed100a56 100644 --- a/spec/frontend/alert_management/components/alert_management_detail_spec.js +++ b/spec/frontend/alert_management/components/alert_details_spec.js @@ -20,6 +20,7 @@ describe('AlertDetails', () => { const projectPath = 'root/alerts'; const projectIssuesPath = 'root/alerts/-/issues'; const projectId = '1'; + const $router = { replace: jest.fn() }; const findDetailsTable = () => wrapper.find(GlTable); @@ -44,6 +45,8 @@ describe('AlertDetails', () => { sidebarStatus: {}, }, }, + $router, + $route: { params: {} }, }, stubs, }); @@ -60,9 +63,9 @@ describe('AlertDetails', () => { mock.restore(); }); - const findCreateIssueBtn = () => wrapper.find('[data-testid="createIssueBtn"]'); - const findViewIssueBtn = () => wrapper.find('[data-testid="viewIssueBtn"]'); - const findIssueCreationAlert = () => wrapper.find('[data-testid="issueCreationError"]'); + const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); + const findViewIncidentBtn = () => wrapper.find('[data-testid="viewIncidentBtn"]'); + const findIncidentCreationAlert = () => wrapper.find('[data-testid="incidentCreationError"]'); describe('Alert details', () => { describe('when alert is null', () => { @@ -81,11 +84,11 @@ describe('AlertDetails', () => { }); it('renders a tab with overview information', () => { - expect(wrapper.find('[data-testid="overviewTab"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="overview"]').exists()).toBe(true); }); it('renders a tab with full alert information', () => { - expect(wrapper.find('[data-testid="fullDetailsTab"]').exists()).toBe(true); + expect(wrapper.find('[data-testid="fullDetails"]').exists()).toBe(true); }); it('renders severity', () => { @@ -115,6 +118,8 @@ describe('AlertDetails', () => { ${'monitoringTool'} | ${undefined} | ${false} ${'service'} | ${'Prometheus'} | ${true} ${'service'} | ${undefined} | ${false} + ${'runbook'} | ${undefined} | ${false} + ${'runbook'} | ${'run.com'} | ${true} `(`$desc`, ({ field, data, isShown }) => { beforeEach(() => { mountComponent({ data: { alert: { ...mockAlert, [field]: data } } }); @@ -130,18 +135,20 @@ describe('AlertDetails', () => { }); }); - describe('Create issue from alert', () => { - it('should display "View issue" button that links the issue page when issue exists', () => { + describe('Create incident from alert', () => { + it('should display "View incident" button that links the incident page when incident exists', () => { const issueIid = '3'; mountComponent({ data: { alert: { ...mockAlert, issueIid }, sidebarStatus: false }, }); - expect(findViewIssueBtn().exists()).toBe(true); - expect(findViewIssueBtn().attributes('href')).toBe(joinPaths(projectIssuesPath, issueIid)); - expect(findCreateIssueBtn().exists()).toBe(false); + expect(findViewIncidentBtn().exists()).toBe(true); + expect(findViewIncidentBtn().attributes('href')).toBe( + joinPaths(projectIssuesPath, issueIid), + ); + expect(findCreateIncidentBtn().exists()).toBe(false); }); - it('should display "Create issue" button when issue doesn\'t exist yet', () => { + it('should display "Create incident" button when incident doesn\'t exist yet', () => { const issueIid = null; mountComponent({ mountMethod: mount, @@ -149,8 +156,8 @@ describe('AlertDetails', () => { }); return wrapper.vm.$nextTick().then(() => { - expect(findViewIssueBtn().exists()).toBe(false); - expect(findCreateIssueBtn().exists()).toBe(true); + expect(findViewIncidentBtn().exists()).toBe(false); + expect(findCreateIncidentBtn().exists()).toBe(true); }); }); @@ -160,7 +167,7 @@ describe('AlertDetails', () => { .spyOn(wrapper.vm.$apollo, 'mutate') .mockResolvedValue({ data: { createAlertIssue: { issue: { iid: issueIid } } } }); - findCreateIssueBtn().trigger('click'); + findCreateIncidentBtn().trigger('click'); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: createIssueMutation, variables: { @@ -170,7 +177,7 @@ describe('AlertDetails', () => { }); }); - it('shows error alert when issue creation fails ', () => { + it('shows error alert when incident creation fails ', () => { const errorMsg = 'Something went wrong'; mountComponent({ mountMethod: mount, @@ -178,10 +185,10 @@ describe('AlertDetails', () => { }); jest.spyOn(wrapper.vm.$apollo, 'mutate').mockRejectedValue(errorMsg); - findCreateIssueBtn().trigger('click'); + findCreateIncidentBtn().trigger('click'); setImmediate(() => { - expect(findIssueCreationAlert().text()).toBe(errorMsg); + expect(findIncidentCreationAlert().text()).toBe(errorMsg); }); }); }); @@ -191,7 +198,7 @@ describe('AlertDetails', () => { mountComponent({ data: { alert: mockAlert } }); }); it('should display a table of raw alert details data', () => { - wrapper.find('[data-testid="fullDetailsTab"]').trigger('click'); + wrapper.find('[data-testid="fullDetails"]').trigger('click'); expect(findDetailsTable().exists()).toBe(true); }); }); @@ -252,6 +259,22 @@ describe('AlertDetails', () => { ); }); }); + + describe('tab navigation', () => { + beforeEach(() => { + mountComponent({ data: { alert: mockAlert } }); + }); + + it.each` + index | tabId + ${0} | ${'overview'} + ${1} | ${'fullDetails'} + ${2} | ${'metrics'} + `('will navigate to the correct tab via $tabId', ({ index, tabId }) => { + wrapper.setData({ currentTabIndex: index }); + expect($router.replace).toHaveBeenCalledWith({ name: 'tab', params: { tabId } }); + }); + }); }); describe('Snowplow tracking', () => { diff --git a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js index 0d1214211d3..6712282503d 100644 --- a/spec/frontend/alert_management/components/alert_management_empty_state_spec.js +++ b/spec/frontend/alert_management/components/alert_management_empty_state_spec.js @@ -15,6 +15,7 @@ describe('AlertManagementEmptyState', () => { wrapper = shallowMount(AlertManagementEmptyState, { propsData: { enableAlertManagementPath: '/link', + alertsHelpUrl: '/link', emptyAlertSvgPath: 'illustration/path', ...props, }, diff --git a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js index 4644406c037..c36107c28ce 100644 --- a/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js +++ b/spec/frontend/alert_management/components/alert_management_list_wrapper_spec.js @@ -19,6 +19,7 @@ describe('AlertManagementList', () => { propsData: { projectPath: 'gitlab-org/gitlab', enableAlertManagementPath: '/link', + alertsHelpUrl: '/link', populatingAlertsHelpUrl: '/help/help-page.md#populating-alert-data', emptyAlertSvgPath: 'illustration/path', ...props, 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 fe08cf2c10a..2814b5ce357 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,6 @@ 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.graphql'; +import AlertMarkTodo from '~/alert_management/graphql/mutations/alert_todo_create.mutation.graphql'; import mockAlerts from '../mocks/alerts.json'; const mockAlert = mockAlerts[0]; @@ -34,6 +34,8 @@ describe('Alert Details Sidebar To Do', () => { wrapper.destroy(); }); + const findToDoButton = () => wrapper.find('[data-testid="alert-todo-button"]'); + describe('updating the alert to do', () => { const mockUpdatedMutationResult = { data: { @@ -44,25 +46,27 @@ describe('Alert Details Sidebar To Do', () => { }, }; - beforeEach(() => { - mountComponent({ - data: { alert: mockAlert }, - sidebarCollapsed: false, - loading: false, + describe('adding a todo', () => { + beforeEach(() => { + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); }); - }); - it('renders a button for adding a To Do', () => { - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find('[data-testid="alert-todo-button"]').text()).toBe('Add a To Do'); + it('renders a button for adding a To-Do', async () => { + await wrapper.vm.$nextTick(); + + expect(findToDoButton().text()).toBe('Add a To-Do'); }); - }); - it('calls `$apollo.mutate` with `AlertMarkTodo` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + it('calls `$apollo.mutate` with `AlertMarkTodo` mutation and variables containing `iid`, `todoEvent`, & `projectPath`', async () => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); + + findToDoButton().trigger('click'); + await wrapper.vm.$nextTick(); - return wrapper.vm.$nextTick().then(() => { - wrapper.find('button').trigger('click'); expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ mutation: AlertMarkTodo, variables: { @@ -72,5 +76,28 @@ describe('Alert Details Sidebar To Do', () => { }); }); }); + describe('removing a todo', () => { + beforeEach(() => { + mountComponent({ + data: { alert: { ...mockAlert, todos: { nodes: [{ id: '1234' }] } } }, + sidebarCollapsed: false, + loading: false, + }); + }); + + it('renders a Mark As Done button when todo is present', async () => { + await wrapper.vm.$nextTick(); + + expect(findToDoButton().text()).toBe('Mark as done'); + }); + + it('calls `$apollo.mutate` with `AlertMarkTodoDone` 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); + }); + }); }); }); 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 f316126432e..5dd0d9dc1ba 100644 --- a/spec/frontend/alert_management/components/alert_management_table_spec.js +++ b/spec/frontend/alert_management/components/alert_management_table_spec.js @@ -3,8 +3,8 @@ import { GlTable, GlAlert, GlLoadingIcon, - GlDropdown, - GlDropdownItem, + GlDeprecatedDropdown, + GlDeprecatedDropdownItem, GlIcon, GlTabs, GlTab, @@ -12,6 +12,7 @@ import { GlPagination, GlSearchBoxByType, } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import { visitUrl } from '~/lib/utils/url_utility'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import AlertManagementTable from '~/alert_management/components/alert_management_table.vue'; @@ -32,18 +33,19 @@ describe('AlertManagementTable', () => { const findAlerts = () => wrapper.findAll('table tbody tr'); const findAlert = () => wrapper.find(GlAlert); const findLoader = () => wrapper.find(GlLoadingIcon); - const findStatusDropdown = () => wrapper.find(GlDropdown); + const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown); const findStatusFilterTabs = () => wrapper.findAll(GlTab); const findStatusTabs = () => wrapper.find(GlTabs); const findStatusFilterBadge = () => wrapper.findAll(GlBadge); const findDateFields = () => wrapper.findAll(TimeAgo); - const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + 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 findIssueFields = () => wrapper.findAll('[data-testid="issueField"]'); + const findAlertError = () => wrapper.find('[data-testid="alert-error"]'); const alertsCount = { open: 14, triggered: 10, @@ -51,6 +53,11 @@ describe('AlertManagementTable', () => { resolved: 1, all: 16, }; + const selectFirstStatusOption = () => { + findFirstStatusOption().vm.$emit('click'); + + return waitForPromises(); + }; function mountComponent({ props = { @@ -138,7 +145,7 @@ describe('AlertManagementTable', () => { it('error state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { errors: ['error'] }, alertsCount: null, errored: true }, + data: { alerts: { errors: ['error'] }, alertsCount: null, hasError: true }, loading: false, }); expect(findAlertsTable().exists()).toBe(true); @@ -155,7 +162,7 @@ describe('AlertManagementTable', () => { it('empty state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, errored: false }, + data: { alerts: { list: [], pageInfo: {} }, alertsCount: { all: 0 }, hasError: false }, loading: false, }); expect(findAlertsTable().exists()).toBe(true); @@ -172,7 +179,7 @@ describe('AlertManagementTable', () => { it('has data state', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); expect(findLoader().exists()).toBe(false); @@ -188,7 +195,7 @@ describe('AlertManagementTable', () => { it('displays status dropdown', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); expect(findStatusDropdown().exists()).toBe(true); @@ -197,7 +204,7 @@ describe('AlertManagementTable', () => { it('does not display a dropdown status header', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); expect(findStatusDropdown().contains('.dropdown-title')).toBe(false); @@ -206,7 +213,7 @@ describe('AlertManagementTable', () => { it('shows correct severity icons', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); @@ -223,7 +230,7 @@ describe('AlertManagementTable', () => { it('renders severity text', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); @@ -237,7 +244,7 @@ describe('AlertManagementTable', () => { it('renders Unassigned when no assignee(s) present', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); @@ -251,7 +258,7 @@ describe('AlertManagementTable', () => { it('renders username(s) when assignee(s) present', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); @@ -265,7 +272,7 @@ describe('AlertManagementTable', () => { it('navigates to the detail page when alert row is clicked', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); @@ -279,7 +286,7 @@ describe('AlertManagementTable', () => { beforeEach(() => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); }); @@ -323,7 +330,7 @@ describe('AlertManagementTable', () => { ], }, alertsCount, - errored: false, + hasError: false, }, loading: false, }); @@ -343,7 +350,7 @@ describe('AlertManagementTable', () => { }, ], alertsCount, - errored: false, + hasError: false, }, loading: false, }); @@ -358,7 +365,7 @@ describe('AlertManagementTable', () => { it('should highlight the row when alert is new', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: [newAlert] }, alertsCount, errored: false }, + data: { alerts: { list: [newAlert] }, alertsCount, hasError: false }, loading: false, }); @@ -372,7 +379,7 @@ describe('AlertManagementTable', () => { it('should not highlight the row when alert is not new', () => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: [oldAlert] }, alertsCount, errored: false }, + data: { alerts: { list: [oldAlert] }, alertsCount, hasError: false }, loading: false, }); @@ -392,7 +399,7 @@ describe('AlertManagementTable', () => { props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, data: { alerts: { list: mockAlerts }, - errored: false, + hasError: false, sort: 'STARTED_AT_DESC', alertsCount, }, @@ -429,7 +436,7 @@ describe('AlertManagementTable', () => { beforeEach(() => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); }); @@ -448,19 +455,36 @@ describe('AlertManagementTable', () => { }); }); - it('shows an error when request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - findFirstStatusOption().vm.$emit('click'); - wrapper.setData({ - errored: true, + describe('when a request fails', () => { + beforeEach(() => { + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.find('[data-testid="alert-error"]').exists()).toBe(true); + it('shows an error', async () => { + await selectFirstStatusOption(); + + expect(findAlertError().text()).toContain( + 'There was an error while updating the status of the alert.', + ); + }); + + it('shows an error when triggered a second time', async () => { + await selectFirstStatusOption(); + + wrapper.find(GlAlert).vm.$emit('dismiss'); + + await wrapper.vm.$nextTick(); + + // Assert that the error has been dismissed in the setup + expect(findAlertError().exists()).toBe(false); + + await selectFirstStatusOption(); + + expect(findAlertError().exists()).toBe(true); }); }); - it('shows an error when response includes HTML errors', () => { + it('shows an error when response includes HTML errors', async () => { const mockUpdatedMutationErrorResult = { data: { updateAlertStatus: { @@ -474,13 +498,11 @@ describe('AlertManagementTable', () => { }; jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationErrorResult); - findFirstStatusOption().vm.$emit('click'); - wrapper.setData({ errored: true }); - return wrapper.vm.$nextTick(() => { - expect(wrapper.contains('[data-testid="alert-error"]')).toBe(true); - expect(wrapper.contains('[data-testid="htmlError"]')).toBe(true); - }); + await selectFirstStatusOption(); + + expect(findAlertError().exists()).toBe(true); + expect(findAlertError().contains('[data-testid="htmlError"]')).toBe(true); }); }); @@ -510,7 +532,7 @@ describe('AlertManagementTable', () => { beforeEach(() => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts, pageInfo: {} }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts, pageInfo: {} }, alertsCount, hasError: false }, loading: false, }); }); @@ -570,7 +592,7 @@ describe('AlertManagementTable', () => { beforeEach(() => { mountComponent({ props: { alertManagementEnabled: true, userCanEnableAlertManagement: true }, - data: { alerts: { list: mockAlerts }, alertsCount, errored: false }, + data: { alerts: { list: mockAlerts }, alertsCount, hasError: false }, loading: false, }); }); diff --git a/spec/frontend/alert_management/components/alert_metrics_spec.js b/spec/frontend/alert_management/components/alert_metrics_spec.js index c188363ddc2..e0a069fa1a8 100644 --- a/spec/frontend/alert_management/components/alert_metrics_spec.js +++ b/spec/frontend/alert_management/components/alert_metrics_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import AlertMetrics from '~/alert_management/components/alert_metrics.vue'; import MockAdapter from 'axios-mock-adapter'; import axios from 'axios'; +import AlertMetrics from '~/alert_management/components/alert_metrics.vue'; jest.mock('~/monitoring/stores', () => ({ monitoringDashboard: {}, 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 db086782424..a14596b6722 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 @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem } from '@gitlab/ui'; import SidebarAssignee from '~/alert_management/components/sidebar/sidebar_assignee.vue'; import SidebarAssignees from '~/alert_management/components/sidebar/sidebar_assignees.vue'; import AlertSetAssignees from '~/alert_management/graphql/mutations/alert_set_assignees.mutation.graphql'; @@ -103,7 +103,7 @@ describe('Alert Details Sidebar Assignees', () => { it('renders a unassigned option', () => { wrapper.setData({ isDropdownSearching: false }); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(GlDropdownItem).text()).toBe('Unassigned'); + expect(wrapper.find(GlDeprecatedDropdownItem).text()).toBe('Unassigned'); }); }); 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 c2eaf540e9c..5bd0d3b3c17 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 @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem, GlLoadingIcon } from '@gitlab/ui'; import { trackAlertStatusUpdateOptions } from '~/alert_management/constants'; import AlertSidebarStatus from '~/alert_management/components/sidebar/sidebar_status.vue'; import updateAlertStatus from '~/alert_management/graphql/mutations/update_alert_status.mutation.graphql'; @@ -10,8 +10,8 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Status', () => { let wrapper; - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findStatusDropdownItem = () => wrapper.find(GlDropdownItem); + const findStatusDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findStatusDropdownItem = () => wrapper.find(GlDeprecatedDropdownItem); const findStatusLoadingIcon = () => wrapper.find(GlLoadingIcon); function mountComponent({ data, sidebarCollapsed = true, loading = false, stubs = {} } = {}) { diff --git a/spec/frontend/alert_management/mocks/alerts.json b/spec/frontend/alert_management/mocks/alerts.json index f63019d1e5c..fec101a52b4 100644 --- a/spec/frontend/alert_management/mocks/alerts.json +++ b/spec/frontend/alert_management/mocks/alerts.json @@ -9,7 +9,8 @@ "endedAt": "2020-04-17T23:18:14.996Z", "status": "TRIGGERED", "assignees": { "nodes": [] }, - "notes": { "nodes": [] } + "notes": { "nodes": [] }, + "todos": { "nodes": [] } }, { "iid": "1527543", @@ -37,7 +38,8 @@ "systemNoteIconName": "user" } ] - } + }, + "todos": { "nodes": [] } }, { "iid": "1527544", @@ -63,6 +65,7 @@ } } ] - } + }, + "todos": { "nodes": [] } } ] diff --git a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap index 1f5c3a80fbb..16e92bf505a 100644 --- a/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap +++ b/spec/frontend/alert_settings/__snapshots__/alert_settings_form_spec.js.snap @@ -13,20 +13,20 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] </div> <gl-form-stub> <gl-form-group-stub label=\\"Integrations\\" label-for=\\"integrations\\" label-class=\\"label-bold\\"> - <gl-form-select-stub options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-400\\"><gl-sprintf-stub message=\\"Learn more about our %{linkStart}upcoming integrations%{linkEnd}\\"></gl-sprintf-stub></span> + <gl-form-select-stub options=\\"[object Object],[object Object],[object Object]\\" data-testid=\\"alert-settings-select\\" value=\\"generic\\"></gl-form-select-stub> <span class=\\"gl-text-gray-200\\"><gl-sprintf-stub message=\\"Learn more about our %{linkStart}upcoming integrations%{linkEnd}\\"></gl-sprintf-stub></span> </gl-form-group-stub> <gl-form-group-stub label=\\"Active\\" label-for=\\"activated\\" label-class=\\"label-bold\\"> <toggle-button-stub id=\\"activated\\"></toggle-button-stub> </gl-form-group-stub> <!----> <gl-form-group-stub label=\\"Webhook URL\\" label-for=\\"url\\" label-class=\\"label-bold\\"> - <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-400\\"> + <gl-form-input-group-stub value=\\"/alerts/notify.json\\" predefinedoptions=\\"[object Object]\\" id=\\"url\\" readonly=\\"\\"></gl-form-input-group-stub> <span class=\\"gl-text-gray-200\\"> </span> </gl-form-group-stub> <gl-form-group-stub label=\\"Authorization key\\" label-for=\\"authorization-key\\" label-class=\\"label-bold\\"> <gl-form-input-group-stub value=\\"abcedfg123\\" predefinedoptions=\\"[object Object]\\" id=\\"authorization-key\\" readonly=\\"\\" class=\\"gl-mb-2\\"></gl-form-input-group-stub> - <gl-button-stub category=\\"tertiary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\" class=\\"gl-mt-3\\" role=\\"button\\" tabindex=\\"0\\">Reset key</gl-button-stub> <gl-modal-stub modalid=\\"authKeyModal\\" titletag=\\"h4\\" modalclass=\\"\\" size=\\"md\\" title=\\"Reset key\\" ok-title=\\"Reset key\\" ok-variant=\\"danger\\"> Resetting the authorization key for this project will require updating the authorization key in every alert source it is enabled in. </gl-modal-stub> @@ -34,14 +34,16 @@ exports[`AlertsSettingsForm with default values renders the initial template 1`] <gl-form-group-stub label=\\"Alert test payload\\" label-for=\\"alert-json\\" label-class=\\"label-bold\\"> <gl-form-textarea-stub noresize=\\"true\\" id=\\"alert-json\\" disabled=\\"true\\" state=\\"true\\" placeholder=\\"Enter test alert JSON....\\" rows=\\"6\\" max-rows=\\"10\\"></gl-form-textarea-stub> </gl-form-group-stub> - <gl-button-stub category=\\"tertiary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> + <div class=\\"gl-display-flex gl-justify-content-end\\"> + <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\">Test alert payload</gl-button-stub> + </div> <div class=\\"footer-block row-content-block gl-display-flex gl-justify-content-space-between\\"> - <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> - Save changes - </gl-button-stub> <gl-button-stub category=\\"primary\\" variant=\\"default\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> Cancel </gl-button-stub> + <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" disabled=\\"true\\"> + Save changes + </gl-button-stub> </div> </gl-form-stub> </div>" diff --git a/spec/frontend/alert_settings/alert_settings_form_spec.js b/spec/frontend/alert_settings/alert_settings_form_spec.js index 5a04d768645..87a631bda56 100644 --- a/spec/frontend/alert_settings/alert_settings_form_spec.js +++ b/spec/frontend/alert_settings/alert_settings_form_spec.js @@ -11,41 +11,36 @@ const KEY = 'abcedfg123'; const INVALID_URL = 'http://invalid'; const ACTIVATED = false; -const defaultProps = { - generic: { - initialAuthorizationKey: KEY, - formPath: INVALID_URL, - url: GENERIC_URL, - alertsSetupUrl: INVALID_URL, - alertsUsageUrl: INVALID_URL, - activated: ACTIVATED, - }, - prometheus: { - prometheusAuthorizationKey: KEY, - prometheusFormPath: INVALID_URL, - prometheusUrl: PROMETHEUS_URL, - activated: ACTIVATED, - }, - opsgenie: { - opsgenieMvcIsAvailable: true, - formPath: INVALID_URL, - activated: ACTIVATED, - opsgenieMvcTargetUrl: GENERIC_URL, - }, -}; - describe('AlertsSettingsForm', () => { let wrapper; let mockAxios; - const createComponent = (props = defaultProps, { methods } = {}, data) => { + const createComponent = ({ methods } = {}, data) => { wrapper = shallowMount(AlertsSettingsForm, { data() { return { ...data }; }, - propsData: { - ...defaultProps, - ...props, + provide: { + generic: { + authorizationKey: KEY, + formPath: INVALID_URL, + url: GENERIC_URL, + alertsSetupUrl: INVALID_URL, + alertsUsageUrl: INVALID_URL, + activated: ACTIVATED, + }, + prometheus: { + authorizationKey: KEY, + prometheusFormPath: INVALID_URL, + prometheusUrl: PROMETHEUS_URL, + activated: ACTIVATED, + }, + opsgenie: { + opsgenieMvcIsAvailable: true, + formPath: INVALID_URL, + activated: ACTIVATED, + opsgenieMvcTargetUrl: GENERIC_URL, + }, }, methods, }); @@ -83,32 +78,33 @@ describe('AlertsSettingsForm', () => { describe('reset key', () => { it('triggers resetKey method', () => { - const resetGenericKey = jest.fn(); - const methods = { resetGenericKey }; - createComponent(defaultProps, { methods }); + const resetKey = jest.fn(); + const methods = { resetKey }; + createComponent({ methods }); wrapper.find(GlModal).vm.$emit('ok'); - expect(resetGenericKey).toHaveBeenCalled(); + expect(resetKey).toHaveBeenCalled(); }); it('updates the authorization key on success', () => { - const formPath = 'some/path'; - mockAxios.onPut(formPath, { service: { token: '' } }).replyOnce(200, { token: 'newToken' }); - createComponent({ generic: { ...defaultProps.generic, formPath } }); + createComponent( + {}, + { + authKey: 'newToken', + }, + ); - return wrapper.vm.resetGenericKey().then(() => { - expect(findAuthorizationKey().attributes('value')).toBe('newToken'); - }); + expect(findAuthorizationKey().attributes('value')).toBe('newToken'); }); it('shows a alert message on error', () => { const formPath = 'some/path'; mockAxios.onPut(formPath).replyOnce(404); - createComponent({ generic: { ...defaultProps.generic, formPath } }); + createComponent(); - return wrapper.vm.resetGenericKey().then(() => { + return wrapper.vm.resetKey().then(() => { expect(wrapper.find(GlAlert).exists()).toBe(true); }); }); @@ -118,22 +114,18 @@ describe('AlertsSettingsForm', () => { it('triggers toggleActivated method', () => { const toggleService = jest.fn(); const methods = { toggleService }; - createComponent(defaultProps, { methods }); + createComponent({ methods }); wrapper.find(ToggleButton).vm.$emit('change', true); - expect(toggleService).toHaveBeenCalled(); }); describe('error is encountered', () => { - beforeEach(() => { + it('restores previous value', () => { const formPath = 'some/path'; mockAxios.onPut(formPath).replyOnce(500); - }); - - it('restores previous value', () => { - createComponent({ generic: { ...defaultProps.generic, initialActivated: false } }); - return wrapper.vm.resetGenericKey().then(() => { + createComponent(); + return wrapper.vm.resetKey().then(() => { expect(wrapper.find(ToggleButton).props('value')).toBe(false); }); }); @@ -143,7 +135,6 @@ describe('AlertsSettingsForm', () => { describe('prometheus is active', () => { beforeEach(() => { createComponent( - { prometheus: { ...defaultProps.prometheus, prometheusIsActivated: true } }, {}, { selectedEndpoint: 'prometheus', @@ -164,10 +155,9 @@ describe('AlertsSettingsForm', () => { }); }); - describe('opsgenie is active', () => { + describe('Opsgenie is active', () => { beforeEach(() => { createComponent( - { opsgenie: { ...defaultProps.opsgenie, opsgenieMvcActivated: true } }, {}, { selectedEndpoint: 'opsgenie', @@ -175,15 +165,14 @@ describe('AlertsSettingsForm', () => { ); }); - it('shows a input for the opsgenie target URL', () => { + it('shows a input for the Opsgenie target URL', () => { expect(findApiUrl().exists()).toBe(true); - expect(findSelect().attributes('value')).toBe('opsgenie'); }); }); describe('trigger test alert', () => { beforeEach(() => { - createComponent({ generic: { ...defaultProps.generic, initialActivated: true } }, {}, true); + createComponent({}); }); it('should enable the JSON input', () => { @@ -191,30 +180,19 @@ describe('AlertsSettingsForm', () => { expect(findJsonInput().props('value')).toBe(null); }); - it('should validate JSON input', () => { - createComponent({ generic: { ...defaultProps.generic } }, true, { + it('should validate JSON input', async () => { + createComponent(true, { testAlertJson: '{ "value": "test" }', }); findJsonInput().vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findJsonInput().attributes('state')).toBe('true'); - }); - }); - - describe('alert service is toggled', () => { - it('should show a info alert if successful', () => { - const formPath = 'some/path'; - const toggleService = true; - mockAxios.onPut(formPath).replyOnce(200); - createComponent({ generic: { ...defaultProps.generic, formPath } }); + await wrapper.vm.$nextTick(); - return wrapper.vm.toggleActivated(toggleService).then(() => { - expect(wrapper.find(GlAlert).attributes('variant')).toBe('info'); - }); - }); + expect(findJsonInput().attributes('state')).toBe('true'); + }); + describe('alert service is toggled', () => { it('should show a error alert if failed', () => { const formPath = 'some/path'; const toggleService = true; @@ -222,9 +200,10 @@ describe('AlertsSettingsForm', () => { errors: 'Error message to display', }); - createComponent({ generic: { ...defaultProps.generic, formPath } }); + createComponent(); return wrapper.vm.toggleActivated(toggleService).then(() => { + expect(wrapper.vm.active).toBe(false); expect(wrapper.find(GlAlert).attributes('variant')).toBe('danger'); }); }); diff --git a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js index 610f9d6b9bd..5574c83eb76 100644 --- a/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js +++ b/spec/frontend/alerts_service_settings/components/alerts_service_form_spec.js @@ -4,7 +4,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlModal } from '@gitlab/ui'; import AlertsServiceForm from '~/alerts_service_settings/components/alerts_service_form.vue'; import ToggleButton from '~/vue_shared/components/toggle_button.vue'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); diff --git a/spec/frontend/analytics/components/activity_chart_spec.js b/spec/frontend/analytics/components/activity_chart_spec.js new file mode 100644 index 00000000000..1f0f9a6c5d7 --- /dev/null +++ b/spec/frontend/analytics/components/activity_chart_spec.js @@ -0,0 +1,39 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlColumnChart } from '@gitlab/ui/dist/charts'; +import ActivityChart from '~/analytics/product_analytics/components/activity_chart.vue'; + +describe('Activity Chart Bundle', () => { + let wrapper; + function mountComponent({ provide }) { + wrapper = shallowMount(ActivityChart, { + provide: { + formattedData: {}, + ...provide, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findChart = () => wrapper.find(GlColumnChart); + const findNoData = () => wrapper.find('[data-testid="noActivityChartData"]'); + + describe('Activity Chart', () => { + it('renders an warning message with no data', () => { + mountComponent({ provide: { formattedData: {} } }); + expect(findNoData().exists()).toBe(true); + }); + + it('renders a chart with data', () => { + mountComponent({ + provide: { formattedData: { keys: ['key1', 'key2'], values: [5038, 2241] } }, + }); + + expect(findNoData().exists()).toBe(false); + expect(findChart().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index c94637e04af..4f4de62c229 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1,6 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import Api from '~/api'; +import httpStatus from '~/lib/utils/http_status'; describe('Api', () => { const dummyApiVersion = 'v3000'; @@ -57,7 +58,7 @@ describe('Api', () => { it('fetch all group packages', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/packages`; jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(200, apiResponse); + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); return Api.groupPackages(groupId).then(({ data }) => { expect(data).toEqual(apiResponse); @@ -70,7 +71,7 @@ describe('Api', () => { it('fetch all project packages', () => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/packages`; jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(200, apiResponse); + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); return Api.projectPackages(projectId).then(({ data }) => { expect(data).toEqual(apiResponse); @@ -92,7 +93,7 @@ describe('Api', () => { const expectedUrl = `foo`; jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(200, apiResponse); + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, apiResponse); return Api.projectPackage(projectId, packageId).then(({ data }) => { expect(data).toEqual(apiResponse); @@ -107,7 +108,7 @@ describe('Api', () => { jest.spyOn(Api, 'buildProjectPackageUrl').mockReturnValue(expectedUrl); jest.spyOn(axios, 'delete'); - mock.onDelete(expectedUrl).replyOnce(200, true); + mock.onDelete(expectedUrl).replyOnce(httpStatus.OK, true); return Api.deleteProjectPackage(projectId, packageId).then(({ data }) => { expect(data).toEqual(true); @@ -121,7 +122,7 @@ describe('Api', () => { it('fetches a group', done => { const groupId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}`; - mock.onGet(expectedUrl).reply(200, { + mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'test', }); @@ -137,7 +138,7 @@ describe('Api', () => { const groupId = '54321'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/members`; const expectedData = [{ id: 7 }]; - mock.onGet(expectedUrl).reply(200, expectedData); + mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); Api.groupMembers(groupId) .then(({ data }) => { @@ -148,12 +149,42 @@ describe('Api', () => { }); }); + describe('groupMilestones', () => { + it('fetches group milestones', done => { + const groupId = '16'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/milestones`; + const expectedData = [ + { + id: 12, + iid: 3, + group_id: 16, + title: '10.0', + description: 'Version', + due_date: '2013-11-29', + start_date: '2013-11-10', + state: 'active', + updated_at: '2013-10-02T09:24:18Z', + created_at: '2013-10-02T09:24:18Z', + web_url: 'https://gitlab.com/groups/gitlab-org/-/milestones/42', + }, + ]; + mock.onGet(expectedUrl).reply(httpStatus.OK, expectedData); + + Api.groupMilestones(groupId) + .then(({ data }) => { + expect(data).toEqual(expectedData); + }) + .then(done) + .catch(done.fail); + }); + }); + describe('groups', () => { it('fetches groups', done => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups.json`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -171,7 +202,7 @@ describe('Api', () => { it('fetches namespaces', done => { const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/namespaces.json`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -191,7 +222,7 @@ describe('Api', () => { const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; window.gon.current_user_id = 1; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -208,7 +239,7 @@ describe('Api', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects.json`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -226,7 +257,7 @@ describe('Api', () => { it('update a project with the given payload', done => { const projectPath = 'foo'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}`; - mock.onPut(expectedUrl).reply(200, { foo: 'bar' }); + mock.onPut(expectedUrl).reply(httpStatus.OK, { foo: 'bar' }); Api.updateProject(projectPath, { foo: 'bar' }) .then(({ data }) => { @@ -243,7 +274,7 @@ describe('Api', () => { const options = { unused: 'option' }; const projectPath = 'gitlab-org%2Fgitlab-ce'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/users`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -265,7 +296,7 @@ describe('Api', () => { it('fetches all merge requests for a project', done => { const mockData = [{ source_branch: 'foo' }, { source_branch: 'bar' }]; - mock.onGet(expectedUrl).reply(200, mockData); + mock.onGet(expectedUrl).reply(httpStatus.OK, mockData); Api.projectMergeRequests(projectPath) .then(({ data }) => { expect(data.length).toEqual(2); @@ -281,7 +312,7 @@ describe('Api', () => { source_branch: 'bar', }; const mockData = [{ source_branch: 'bar' }]; - mock.onGet(expectedUrl, { params }).reply(200, mockData); + mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); Api.projectMergeRequests(projectPath, params) .then(({ data }) => { @@ -298,7 +329,7 @@ describe('Api', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}`; - mock.onGet(expectedUrl).reply(200, { + mock.onGet(expectedUrl).reply(httpStatus.OK, { title: 'test', }); @@ -316,7 +347,7 @@ describe('Api', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/changes`; - mock.onGet(expectedUrl).reply(200, { + mock.onGet(expectedUrl).reply(httpStatus.OK, { title: 'test', }); @@ -334,7 +365,7 @@ describe('Api', () => { const projectPath = 'abc'; const mergeRequestId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/versions`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { id: 123, }, @@ -356,7 +387,7 @@ describe('Api', () => { const params = { scope: 'active' }; const mockData = [{ id: 4 }]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/runners`; - mock.onGet(expectedUrl, { params }).reply(200, mockData); + mock.onGet(expectedUrl, { params }).reply(httpStatus.OK, mockData); Api.projectRunners(projectPath, { params }) .then(({ data }) => { @@ -380,7 +411,7 @@ describe('Api', () => { expect(config.data).toBe(JSON.stringify(expectedData)); return [ - 200, + httpStatus.OK, { name: 'test', }, @@ -404,7 +435,7 @@ describe('Api', () => { expect(config.data).toBe(JSON.stringify(expectedData)); return [ - 200, + httpStatus.OK, { name: 'test', }, @@ -423,7 +454,7 @@ describe('Api', () => { const groupId = '123456'; const query = 'dummy query'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/projects.json`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -445,7 +476,7 @@ describe('Api', () => { )}/repository/commits/${sha}`; it('fetches a single commit', () => { - mock.onGet(expectedUrl).reply(200, { id: sha }); + mock.onGet(expectedUrl).reply(httpStatus.OK, { id: sha }); return Api.commit(projectId, sha).then(({ data: commit }) => { expect(commit.id).toBe(sha); @@ -453,7 +484,7 @@ describe('Api', () => { }); it('fetches a single commit without stats', () => { - mock.onGet(expectedUrl, { params: { stats: false } }).reply(200, { id: sha }); + mock.onGet(expectedUrl, { params: { stats: false } }).reply(httpStatus.OK, { id: sha }); return Api.commit(projectId, sha, { stats: false }).then(({ data: commit }) => { expect(commit.id).toBe(sha); @@ -470,7 +501,7 @@ describe('Api', () => { const expectedUrl = `${dummyUrlRoot}/${namespace}/${project}/templates/${templateType}/${encodeURIComponent( templateKey, )}`; - mock.onGet(expectedUrl).reply(200, 'test'); + mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); Api.issueTemplate(namespace, project, templateKey, templateType, (error, response) => { expect(response).toBe('test'); @@ -483,7 +514,7 @@ describe('Api', () => { it('fetches a list of templates', done => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses`; - mock.onGet(expectedUrl).reply(200, 'test'); + mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); Api.projectTemplates('gitlab-org/gitlab-ce', 'licenses', {}, response => { expect(response).toBe('test'); @@ -497,7 +528,7 @@ describe('Api', () => { const data = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/gitlab-org%2Fgitlab-ce/templates/licenses/test%20license`; - mock.onGet(expectedUrl).reply(200, 'test'); + mock.onGet(expectedUrl).reply(httpStatus.OK, 'test'); Api.projectTemplate('gitlab-org/gitlab-ce', 'licenses', 'test license', data, response => { expect(response).toBe('test'); @@ -511,7 +542,7 @@ describe('Api', () => { const query = 'dummy query'; const options = { unused: 'option' }; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users.json`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -531,7 +562,7 @@ describe('Api', () => { it('fetches single user', done => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}`; - mock.onGet(expectedUrl).reply(200, { + mock.onGet(expectedUrl).reply(httpStatus.OK, { name: 'testuser', }); @@ -547,7 +578,7 @@ describe('Api', () => { describe('user counts', () => { it('fetches single user counts', done => { const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/user_counts`; - mock.onGet(expectedUrl).reply(200, { + mock.onGet(expectedUrl).reply(httpStatus.OK, { merge_requests: 4, }); @@ -564,7 +595,7 @@ describe('Api', () => { it('fetches single user status', done => { const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/status`; - mock.onGet(expectedUrl).reply(200, { + mock.onGet(expectedUrl).reply(httpStatus.OK, { message: 'testmessage', }); @@ -583,7 +614,7 @@ describe('Api', () => { const options = { unused: 'option' }; const userId = '123456'; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/users/${userId}/projects`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -602,7 +633,7 @@ describe('Api', () => { const projectId = 'example/foobar'; const commitSha = 'abc123def'; const expectedUrl = `${dummyUrlRoot}/${projectId}/commit/${commitSha}/pipelines`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -629,7 +660,7 @@ describe('Api', () => { jest.spyOn(axios, 'post'); - mock.onPost(expectedUrl).replyOnce(200, { + mock.onPost(expectedUrl).replyOnce(httpStatus.OK, { name: branch, }); @@ -652,7 +683,7 @@ describe('Api', () => { jest.spyOn(axios, 'get'); - mock.onGet(expectedUrl).replyOnce(200, ['fork']); + mock.onGet(expectedUrl).replyOnce(httpStatus.OK, ['fork']); Api.projectForks(dummyProjectPath, { visibility: 'private' }) .then(({ data }) => { @@ -666,62 +697,239 @@ describe('Api', () => { }); }); - describe('createReleaseLink', () => { + describe('createContextCommits', () => { + it('creates a new context commit', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const commitsData = ['abcdefg']; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`; + const expectedData = { + commits: commitsData, + }; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).replyOnce(200, [ + { + id: 'abcdefghijklmnop', + short_id: 'abcdefg', + title: 'Dummy commit', + }, + ]); + + Api.createContextCommits(projectPath, mergeRequestId, expectedData) + .then(({ data }) => { + expect(data[0].title).toBe('Dummy commit'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('allContextCommits', () => { + it('gets all context commits', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`; + + jest.spyOn(axios, 'get'); + + mock + .onGet(expectedUrl) + .replyOnce(200, [{ id: 'abcdef', short_id: 'abcdefghi', title: 'Dummy commit title' }]); + + Api.allContextCommits(projectPath, mergeRequestId) + .then(({ data }) => { + expect(data[0].title).toBe('Dummy commit title'); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('removeContextCommits', () => { + it('removes context commits', done => { + const projectPath = 'abc'; + const mergeRequestId = '123456'; + const commitsData = ['abcdefg']; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectPath}/merge_requests/${mergeRequestId}/context_commits`; + const expectedData = { + commits: commitsData, + }; + + jest.spyOn(axios, 'delete'); + + mock.onDelete(expectedUrl).replyOnce(204); + + Api.removeContextCommits(projectPath, mergeRequestId, expectedData) + .then(() => { + expect(axios.delete).toHaveBeenCalledWith(expectedUrl, { data: expectedData }); + }) + .then(done) + .catch(done.fail); + }); + }); + + describe('release-related methods', () => { const dummyProjectPath = 'gitlab-org/gitlab'; - const dummyReleaseTag = 'v1.3'; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( + const dummyTagName = 'v1.3'; + const baseReleaseUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( dummyProjectPath, - )}/releases/${dummyReleaseTag}/assets/links`; - const expectedLink = { - url: 'https://example.com', - name: 'An example link', - }; + )}/releases`; - describe('when the Release is successfully created', () => { - it('resolves the Promise', () => { - mock.onPost(expectedUrl, expectedLink).replyOnce(201); + describe('releases', () => { + const expectedUrl = baseReleaseUrl; - return Api.createReleaseLink(dummyProjectPath, dummyReleaseTag, expectedLink).then(() => { - expect(mock.history.post).toHaveLength(1); + describe('when releases are successfully returned', () => { + it('resolves the Promise', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.OK); + + return Api.releases(dummyProjectPath).then(() => { + expect(mock.history.get).toHaveLength(1); + }); + }); + }); + + describe('when an error occurs while fetching releases', () => { + it('rejects the Promise', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + return Api.releases(dummyProjectPath).catch(() => { + expect(mock.history.get).toHaveLength(1); + }); }); }); }); - describe('when an error occurs while creating the Release', () => { - it('rejects the Promise', () => { - mock.onPost(expectedUrl, expectedLink).replyOnce(500); + describe('release', () => { + const expectedUrl = `${baseReleaseUrl}/${encodeURIComponent(dummyTagName)}`; - return Api.createReleaseLink(dummyProjectPath, dummyReleaseTag, expectedLink).catch(() => { - expect(mock.history.post).toHaveLength(1); + describe('when the release is successfully returned', () => { + it('resolves the Promise', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.OK); + + return Api.release(dummyProjectPath, dummyTagName).then(() => { + expect(mock.history.get).toHaveLength(1); + }); + }); + }); + + describe('when an error occurs while fetching the release', () => { + it('rejects the Promise', () => { + mock.onGet(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + return Api.release(dummyProjectPath, dummyTagName).catch(() => { + expect(mock.history.get).toHaveLength(1); + }); }); }); }); - }); - describe('deleteReleaseLink', () => { - const dummyProjectPath = 'gitlab-org/gitlab'; - const dummyReleaseTag = 'v1.3'; - const dummyLinkId = '4'; - const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${encodeURIComponent( - dummyProjectPath, - )}/releases/${dummyReleaseTag}/assets/links/${dummyLinkId}`; + describe('createRelease', () => { + const expectedUrl = baseReleaseUrl; - describe('when the Release is successfully deleted', () => { - it('resolves the Promise', () => { - mock.onDelete(expectedUrl).replyOnce(200); + const release = { + name: 'Version 1.0', + }; + + describe('when the release is successfully created', () => { + it('resolves the Promise', () => { + mock.onPost(expectedUrl, release).replyOnce(httpStatus.CREATED); + + return Api.createRelease(dummyProjectPath, release).then(() => { + expect(mock.history.post).toHaveLength(1); + }); + }); + }); - return Api.deleteReleaseLink(dummyProjectPath, dummyReleaseTag, dummyLinkId).then(() => { - expect(mock.history.delete).toHaveLength(1); + describe('when an error occurs while creating the release', () => { + it('rejects the Promise', () => { + mock.onPost(expectedUrl, release).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + return Api.createRelease(dummyProjectPath, release).catch(() => { + expect(mock.history.post).toHaveLength(1); + }); }); }); }); - describe('when an error occurs while deleting the Release', () => { - it('rejects the Promise', () => { - mock.onDelete(expectedUrl).replyOnce(500); + describe('updateRelease', () => { + const expectedUrl = `${baseReleaseUrl}/${encodeURIComponent(dummyTagName)}`; + + const release = { + name: 'Version 1.0', + }; + + describe('when the release is successfully updated', () => { + it('resolves the Promise', () => { + mock.onPut(expectedUrl, release).replyOnce(httpStatus.OK); + + return Api.updateRelease(dummyProjectPath, dummyTagName, release).then(() => { + expect(mock.history.put).toHaveLength(1); + }); + }); + }); - return Api.deleteReleaseLink(dummyProjectPath, dummyReleaseTag, dummyLinkId).catch(() => { - expect(mock.history.delete).toHaveLength(1); + describe('when an error occurs while updating the release', () => { + it('rejects the Promise', () => { + mock.onPut(expectedUrl, release).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + return Api.updateRelease(dummyProjectPath, dummyTagName, release).catch(() => { + expect(mock.history.put).toHaveLength(1); + }); + }); + }); + }); + + describe('createReleaseLink', () => { + const expectedUrl = `${baseReleaseUrl}/${dummyTagName}/assets/links`; + const expectedLink = { + url: 'https://example.com', + name: 'An example link', + }; + + describe('when the Release is successfully created', () => { + it('resolves the Promise', () => { + mock.onPost(expectedUrl, expectedLink).replyOnce(httpStatus.CREATED); + + return Api.createReleaseLink(dummyProjectPath, dummyTagName, expectedLink).then(() => { + expect(mock.history.post).toHaveLength(1); + }); + }); + }); + + describe('when an error occurs while creating the Release', () => { + it('rejects the Promise', () => { + mock.onPost(expectedUrl, expectedLink).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + return Api.createReleaseLink(dummyProjectPath, dummyTagName, expectedLink).catch(() => { + expect(mock.history.post).toHaveLength(1); + }); + }); + }); + }); + + describe('deleteReleaseLink', () => { + const dummyLinkId = '4'; + const expectedUrl = `${baseReleaseUrl}/${dummyTagName}/assets/links/${dummyLinkId}`; + + describe('when the Release is successfully deleted', () => { + it('resolves the Promise', () => { + mock.onDelete(expectedUrl).replyOnce(httpStatus.OK); + + return Api.deleteReleaseLink(dummyProjectPath, dummyTagName, dummyLinkId).then(() => { + expect(mock.history.delete).toHaveLength(1); + }); + }); + }); + + describe('when an error occurs while deleting the Release', () => { + it('rejects the Promise', () => { + mock.onDelete(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + + return Api.deleteReleaseLink(dummyProjectPath, dummyTagName, dummyLinkId).catch(() => { + expect(mock.history.delete).toHaveLength(1); + }); }); }); }); @@ -736,7 +944,7 @@ describe('Api', () => { describe('when the raw file is successfully fetched', () => { it('resolves the Promise', () => { - mock.onGet(expectedUrl).replyOnce(200); + mock.onGet(expectedUrl).replyOnce(httpStatus.OK); return Api.getRawFile(dummyProjectPath, dummyFilePath).then(() => { expect(mock.history.get).toHaveLength(1); @@ -746,7 +954,7 @@ describe('Api', () => { describe('when an error occurs while getting a raw file', () => { it('rejects the Promise', () => { - mock.onPost(expectedUrl).replyOnce(500); + mock.onPost(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); return Api.getRawFile(dummyProjectPath, dummyFilePath).catch(() => { expect(mock.history.get).toHaveLength(1); @@ -768,7 +976,7 @@ describe('Api', () => { describe('when the merge request is successfully created', () => { it('resolves the Promise', () => { - mock.onPost(expectedUrl, options).replyOnce(201); + mock.onPost(expectedUrl, options).replyOnce(httpStatus.CREATED); return Api.createProjectMergeRequest(dummyProjectPath, options).then(() => { expect(mock.history.post).toHaveLength(1); @@ -778,7 +986,7 @@ describe('Api', () => { describe('when an error occurs while getting a raw file', () => { it('rejects the Promise', () => { - mock.onPost(expectedUrl).replyOnce(500); + mock.onPost(expectedUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); return Api.createProjectMergeRequest(dummyProjectPath).catch(() => { expect(mock.history.post).toHaveLength(1); @@ -793,7 +1001,7 @@ describe('Api', () => { const issue = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/issues/${issue}`; - mock.onPut(expectedUrl).reply(200, { assigneeIds: expectedArray }); + mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); Api.updateIssue(projectId, issue, { assigneeIds: expectedArray }) .then(({ data }) => { @@ -810,7 +1018,7 @@ describe('Api', () => { const mergeRequest = 1; const expectedArray = [1, 2, 3]; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/merge_requests/${mergeRequest}`; - mock.onPut(expectedUrl).reply(200, { assigneeIds: expectedArray }); + mock.onPut(expectedUrl).reply(httpStatus.OK, { assigneeIds: expectedArray }); Api.updateMergeRequest(projectId, mergeRequest, { assigneeIds: expectedArray }) .then(({ data }) => { @@ -827,7 +1035,7 @@ describe('Api', () => { const options = { unused: 'option' }; const projectId = 8; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/repository/tags`; - mock.onGet(expectedUrl).reply(200, [ + mock.onGet(expectedUrl).reply(httpStatus.OK, [ { name: 'test', }, @@ -842,4 +1050,83 @@ describe('Api', () => { .catch(done.fail); }); }); + + describe('freezePeriods', () => { + it('fetches freezePeriods', () => { + const projectId = 8; + const freezePeriod = { + id: 3, + freeze_start: '5 4 * * *', + freeze_end: '5 9 * 8 *', + cron_timezone: 'America/New_York', + created_at: '2020-07-10T05:10:35.122Z', + updated_at: '2020-07-10T05:10:35.122Z', + }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`; + mock.onGet(expectedUrl).reply(httpStatus.OK, [freezePeriod]); + + return Api.freezePeriods(projectId).then(({ data }) => { + expect(data[0]).toStrictEqual(freezePeriod); + }); + }); + }); + + describe('createFreezePeriod', () => { + const projectId = 8; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/freeze_periods`; + const options = { + freeze_start: '* * * * *', + freeze_end: '* * * * *', + cron_timezone: 'America/Juneau', + }; + + const expectedResult = { + id: 10, + freeze_start: '* * * * *', + freeze_end: '* * * * *', + cron_timezone: 'America/Juneau', + created_at: '2020-07-11T07:04:50.153Z', + updated_at: '2020-07-11T07:04:50.153Z', + }; + + describe('when the freeze period is successfully created', () => { + it('resolves the Promise', () => { + mock.onPost(expectedUrl, options).replyOnce(httpStatus.CREATED, expectedResult); + + return Api.createFreezePeriod(projectId, options).then(({ data }) => { + expect(data).toStrictEqual(expectedResult); + }); + }); + }); + }); + + describe('createPipeline', () => { + it('creates new pipeline', () => { + const redirectUrl = 'ci-project/-/pipelines/95'; + const projectId = 8; + const postData = { + ref: 'tag-1', + variables: [ + { key: 'test_file', value: 'test_file_val', variable_type: 'file' }, + { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' }, + ], + }; + const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/pipeline`; + + jest.spyOn(axios, 'post'); + + mock.onPost(expectedUrl).replyOnce(httpStatus.OK, { + web_url: redirectUrl, + }); + + return Api.createPipeline(projectId, postData).then(({ data }) => { + expect(data.web_url).toBe(redirectUrl); + expect(axios.post).toHaveBeenCalledWith(expectedUrl, postData, { + headers: { + 'Content-Type': 'application/json', + }, + }); + }); + }); + }); }); diff --git a/spec/frontend/awards_handler_spec.js b/spec/frontend/awards_handler_spec.js index 6cfbc6024af..1a1738ecf4a 100644 --- a/spec/frontend/awards_handler_spec.js +++ b/spec/frontend/awards_handler_spec.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import Cookies from 'js-cookie'; 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'; -import { useFakeRequestAnimationFrame } from 'helpers/fake_request_animation_frame'; window.gl = window.gl || {}; window.gon = window.gon || {}; @@ -162,7 +162,7 @@ describe('AwardsHandler', () => { describe('::getAwardUrl', () => { it('returns the url for request', () => { - expect(awardsHandler.getAwardUrl()).toBe('http://test.host/snippets/1/toggle_award_emoji'); + expect(awardsHandler.getAwardUrl()).toBe('http://test.host/-/snippets/1/toggle_award_emoji'); }); }); diff --git a/spec/frontend/badges/components/badge_form_spec.js b/spec/frontend/badges/components/badge_form_spec.js index d61bd29ca9d..1edc9adbfb2 100644 --- a/spec/frontend/badges/components/badge_form_spec.js +++ b/spec/frontend/badges/components/badge_form_spec.js @@ -1,11 +1,11 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { DUMMY_IMAGE_URL, TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import store from '~/badges/store'; import createEmptyBadge from '~/badges/empty_badge'; import BadgeForm from '~/badges/components/badge_form.vue'; -import { DUMMY_IMAGE_URL, TEST_HOST } from 'helpers/test_constants'; // avoid preview background process BadgeForm.methods.debouncedPreview = () => {}; @@ -182,11 +182,11 @@ describe('BadgeForm component', () => { const buttons = vm.$el.querySelectorAll('.row-content-block button'); expect(buttons.length).toBe(2); - const buttonSaveElement = buttons[0]; + const buttonSaveElement = buttons[1]; expect(buttonSaveElement).toBeVisible(); expect(buttonSaveElement).toHaveText('Save changes'); - const buttonCancelElement = buttons[1]; + const buttonCancelElement = buttons[0]; expect(buttonCancelElement).toBeVisible(); expect(buttonCancelElement).toHaveText('Cancel'); diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js index eea7f25dbc1..99980c98f8b 100644 --- a/spec/frontend/batch_comments/components/draft_note_spec.js +++ b/spec/frontend/batch_comments/components/draft_note_spec.js @@ -1,4 +1,5 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { getByRole } from '@testing-library/dom'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import { createStore } from '~/batch_comments/stores'; import NoteableNote from '~/notes/components/noteable_note.vue'; @@ -8,21 +9,34 @@ import { createDraft } from '../mock_data'; const localVue = createLocalVue(); describe('Batch comments draft note component', () => { + let store; let wrapper; let draft; + const LINE_RANGE = {}; + const draftWithLineRange = { + position: { + line_range: LINE_RANGE, + }, + }; - beforeEach(() => { - const store = createStore(); - - draft = createDraft(); + const getList = () => getByRole(wrapper.element, 'list'); + const createComponent = (propsData = { draft }, features = {}) => { wrapper = shallowMount(localVue.extend(DraftNote), { store, - propsData: { draft }, + propsData, localVue, + provide: { + glFeatures: { multilineComments: true, ...features }, + }, }); jest.spyOn(wrapper.vm.$store, 'dispatch').mockImplementation(); + }; + + beforeEach(() => { + store = createStore(); + draft = createDraft(); }); afterEach(() => { @@ -30,6 +44,7 @@ describe('Batch comments draft note component', () => { }); it('renders template', () => { + createComponent(); expect(wrapper.find('.draft-pending-label').exists()).toBe(true); const note = wrapper.find(NoteableNote); @@ -40,6 +55,7 @@ describe('Batch comments draft note component', () => { describe('add comment now', () => { it('dispatches publishSingleDraft when clicking', () => { + createComponent(); const publishNowButton = wrapper.find({ ref: 'publishNowButton' }); publishNowButton.vm.$emit('click'); @@ -50,6 +66,7 @@ describe('Batch comments draft note component', () => { }); it('sets as loading when draft is publishing', done => { + createComponent(); wrapper.vm.$store.state.batchComments.currentlyPublishingDrafts.push(1); wrapper.vm.$nextTick(() => { @@ -64,6 +81,7 @@ describe('Batch comments draft note component', () => { describe('update', () => { it('dispatches updateDraft', done => { + createComponent(); const note = wrapper.find(NoteableNote); note.vm.$emit('handleEdit'); @@ -91,6 +109,7 @@ describe('Batch comments draft note component', () => { describe('deleteDraft', () => { it('dispatches deleteDraft', () => { + createComponent(); jest.spyOn(window, 'confirm').mockImplementation(() => true); const note = wrapper.find(NoteableNote); @@ -103,6 +122,7 @@ describe('Batch comments draft note component', () => { describe('quick actions', () => { it('renders referenced commands', done => { + createComponent(); wrapper.setProps({ draft: { ...draft, @@ -122,4 +142,26 @@ describe('Batch comments draft note component', () => { }); }); }); + + describe('multiline comments', () => { + describe.each` + desc | props | features | event | expectedCalls + ${'with `draft.position`'} | ${draftWithLineRange} | ${{}} | ${'mouseenter'} | ${[['setSelectedCommentPositionHover', LINE_RANGE]]} + ${'with `draft.position`'} | ${draftWithLineRange} | ${{}} | ${'mouseleave'} | ${[['setSelectedCommentPositionHover']]} + ${'with `draft.position`'} | ${draftWithLineRange} | ${{ multilineComments: false }} | ${'mouseenter'} | ${[]} + ${'with `draft.position`'} | ${draftWithLineRange} | ${{ multilineComments: false }} | ${'mouseleave'} | ${[]} + ${'without `draft.position`'} | ${{}} | ${{}} | ${'mouseenter'} | ${[]} + ${'without `draft.position`'} | ${{}} | ${{}} | ${'mouseleave'} | ${[]} + `('$desc and features $features', ({ props, event, features, expectedCalls }) => { + beforeEach(() => { + createComponent({ draft: { ...draft, ...props } }, features); + jest.spyOn(store, 'dispatch'); + }); + + it(`calls store ${expectedCalls.length} times on ${event}`, () => { + getList().dispatchEvent(new MouseEvent(event, { bubbles: true })); + expect(store.dispatch.mock.calls).toEqual(expectedCalls); + }); + }); + }); }); diff --git a/spec/frontend/batch_comments/components/drafts_count_spec.js b/spec/frontend/batch_comments/components/drafts_count_spec.js index 9d9fffce7e7..83d2f9eb639 100644 --- a/spec/frontend/batch_comments/components/drafts_count_spec.js +++ b/spec/frontend/batch_comments/components/drafts_count_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import DraftsCount from '~/batch_comments/components/drafts_count.vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import DraftsCount from '~/batch_comments/components/drafts_count.vue'; import { createStore } from '~/batch_comments/stores'; describe('Batch comments drafts count component', () => { @@ -24,7 +24,7 @@ describe('Batch comments drafts count component', () => { }); it('renders count', () => { - expect(vm.$el.querySelector('.drafts-count-number').textContent).toBe('1'); + expect(vm.$el.textContent).toContain('1'); }); it('renders screen reader text', done => { diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 7d951fd7799..2b63ece28ba 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import PreviewItem from '~/batch_comments/components/preview_item.vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import PreviewItem from '~/batch_comments/components/preview_item.vue'; import { createStore } from '~/batch_comments/stores'; import diffsModule from '~/diffs/store/modules'; import notesModule from '~/notes/stores/modules'; diff --git a/spec/frontend/batch_comments/components/publish_button_spec.js b/spec/frontend/batch_comments/components/publish_button_spec.js index 97f3a1c8939..4362f62c7f8 100644 --- a/spec/frontend/batch_comments/components/publish_button_spec.js +++ b/spec/frontend/batch_comments/components/publish_button_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import PublishButton from '~/batch_comments/components/publish_button.vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import PublishButton from '~/batch_comments/components/publish_button.vue'; import { createStore } from '~/batch_comments/stores'; describe('Batch comments publish button component', () => { diff --git a/spec/frontend/batch_comments/components/publish_dropdown_spec.js b/spec/frontend/batch_comments/components/publish_dropdown_spec.js index b50ae340691..fb3c532174d 100644 --- a/spec/frontend/batch_comments/components/publish_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/publish_dropdown_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import PreviewDropdown from '~/batch_comments/components/preview_dropdown.vue'; import { createStore } from '~/mr_notes/stores'; import '~/behaviors/markdown/render_gfm'; import { createDraft } from '../mock_data'; diff --git a/spec/frontend/batch_comments/mock_data.js b/spec/frontend/batch_comments/mock_data.js index c50fea94fe3..5601e489066 100644 --- a/spec/frontend/batch_comments/mock_data.js +++ b/spec/frontend/batch_comments/mock_data.js @@ -1,5 +1,6 @@ import { TEST_HOST } from 'spec/test_constants'; +// eslint-disable-next-line import/prefer-default-export export const createDraft = () => ({ author: { id: 1, @@ -23,5 +24,3 @@ export const createDraft = () => ({ isDraft: true, position: null, }); - -export default () => {}; diff --git a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js index 4bac6d4e3dc..a6942115649 100644 --- a/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js +++ b/spec/frontend/batch_comments/stores/modules/batch_comments/actions_spec.js @@ -1,8 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import * as actions from '~/batch_comments/stores/modules/batch_comments/actions'; import axios from '~/lib/utils/axios_utils'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Batch comments store actions', () => { let res = {}; diff --git a/spec/frontend/behaviors/copy_as_gfm_spec.js b/spec/frontend/behaviors/copy_as_gfm_spec.js index 33af9bc135e..46d4451c941 100644 --- a/spec/frontend/behaviors/copy_as_gfm_spec.js +++ b/spec/frontend/behaviors/copy_as_gfm_spec.js @@ -123,4 +123,14 @@ describe('CopyAsGFM', () => { }); }); }); + + describe('CopyAsGFM.quoted', () => { + const sampleGFM = '* List 1\n* List 2\n\n`Some code`'; + + it('adds quote char `> ` to each line', done => { + const expectedQuotedGFM = '> * List 1\n> * List 2\n> \n> `Some code`'; + expect(CopyAsGFM.quoted(sampleGFM)).toEqual(expectedQuotedGFM); + done(); + }); + }); }); diff --git a/spec/frontend/behaviors/gl_emoji_spec.js b/spec/frontend/behaviors/gl_emoji_spec.js index 7ea0bafc328..ef6b1673b7c 100644 --- a/spec/frontend/behaviors/gl_emoji_spec.js +++ b/spec/frontend/behaviors/gl_emoji_spec.js @@ -1,10 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'jest/helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import { initEmojiMap, EMOJI_VERSION } from '~/emoji'; import installGlEmojiElement from '~/behaviors/gl_emoji'; import * as EmojiUnicodeSupport from '~/emoji/support'; -import waitForPromises from 'jest/helpers/wait_for_promises'; jest.mock('~/emoji/support'); diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_content_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_content_spec.js.snap index 0409b118222..72761c18b3d 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_edit_content_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_edit_content_spec.js.snap @@ -4,11 +4,15 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = ` <div class="file-content code" > - <pre + <div data-editor-loading="" id="editor" > - Lorem ipsum dolor sit amet, consectetur adipiscing elit. - </pre> + <pre + class="editor-loading-content" + > + Lorem ipsum dolor sit amet, consectetur adipiscing elit. + </pre> + </div> </div> `; diff --git a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap index 1e639f91797..a5690844053 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_edit_header_spec.js.snap @@ -4,13 +4,18 @@ exports[`Blob Header Editing rendering matches the snapshot 1`] = ` <div class="js-file-title file-title-flex-parent" > - <gl-form-input-stub - class="form-control js-snippet-file-name" - id="snippet_file_name" - name="snippet_file_name" - placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby" - type="text" - value="foo.md" - /> + <div + class="gl-display-flex gl-align-items-center gl-w-full" + > + <gl-form-input-stub + class="form-control js-snippet-file-name" + name="snippet_file_name" + placeholder="Give your file a name to add code highlighting, e.g. example.rb for Ruby" + type="text" + value="foo.md" + /> + + <!----> + </div> </div> `; diff --git a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap index 7d868625956..b54efb93bc9 100644 --- a/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap +++ b/spec/frontend/blob/components/__snapshots__/blob_header_spec.js.snap @@ -9,7 +9,7 @@ exports[`Blob Header Default Actions rendering matches the snapshot 1`] = ` /> <div - class="file-actions d-none d-sm-flex" + class="gl-display-none gl-display-sm-flex" > <viewer-switcher-stub value="simple" diff --git a/spec/frontend/blob/components/blob_content_error_spec.js b/spec/frontend/blob/components/blob_content_error_spec.js index 508b1ed7e68..0c6d269ad05 100644 --- a/spec/frontend/blob/components/blob_content_error_spec.js +++ b/spec/frontend/blob/components/blob_content_error_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import BlobContentError from '~/blob/components/blob_content_error.vue'; import { GlSprintf } from '@gitlab/ui'; +import BlobContentError from '~/blob/components/blob_content_error.vue'; import { BLOB_RENDER_ERRORS } from '~/blob/components/constants'; diff --git a/spec/frontend/blob/components/blob_content_spec.js b/spec/frontend/blob/components/blob_content_spec.js index 244ed41869d..9232a709194 100644 --- a/spec/frontend/blob/components/blob_content_spec.js +++ b/spec/frontend/blob/components/blob_content_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import BlobContent from '~/blob/components/blob_content.vue'; import BlobContentError from '~/blob/components/blob_content_error.vue'; import { @@ -13,7 +14,6 @@ import { RichBlobContentMock, SimpleBlobContentMock, } from './mock_data'; -import { GlLoadingIcon } from '@gitlab/ui'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; describe('Blob Content component', () => { diff --git a/spec/frontend/blob/components/blob_edit_content_spec.js b/spec/frontend/blob/components/blob_edit_content_spec.js index 971ef72521d..3cc210e972c 100644 --- a/spec/frontend/blob/components/blob_edit_content_spec.js +++ b/spec/frontend/blob/components/blob_edit_content_spec.js @@ -1,28 +1,31 @@ import { shallowMount } from '@vue/test-utils'; -import BlobEditContent from '~/blob/components/blob_edit_content.vue'; -import { initEditorLite } from '~/blob/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('~/blob/utils', () => ({ - initEditorLite: jest.fn(), -})); +jest.mock('~/editor/editor_lite'); describe('Blob Header Editing', () => { let wrapper; const value = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; const fileName = 'lorem.txt'; + const fileGlobalId = 'snippet_777'; function createComponent(props = {}) { wrapper = shallowMount(BlobEditContent, { propsData: { value, fileName, + fileGlobalId, ...props, }, }); } beforeEach(() => { + jest.spyOn(utils, 'initEditorLite'); + createComponent(); }); @@ -30,6 +33,15 @@ describe('Blob Header Editing', () => { wrapper.destroy(); }); + const triggerChangeContent = val => { + jest.spyOn(Editor.prototype, 'getValue').mockReturnValue(val); + const [cb] = Editor.prototype.onChangeContent.mock.calls[0]; + + cb(); + + jest.runOnlyPendingTimers(); + }; + describe('rendering', () => { it('matches the snapshot', () => { expect(wrapper.element).toMatchSnapshot(); @@ -51,18 +63,15 @@ describe('Blob Header Editing', () => { it('initialises Editor Lite', () => { const el = wrapper.find({ ref: 'editor' }).element; - expect(initEditorLite).toHaveBeenCalledWith({ + expect(utils.initEditorLite).toHaveBeenCalledWith({ el, blobPath: fileName, + blobGlobalId: fileGlobalId, blobContent: value, }); }); it('reacts to the changes in fileName', () => { - wrapper.vm.editor = { - updateModelLanguage: jest.fn(), - }; - const newFileName = 'ipsum.txt'; wrapper.setProps({ @@ -70,21 +79,20 @@ describe('Blob Header Editing', () => { }); return nextTick().then(() => { - expect(wrapper.vm.editor.updateModelLanguage).toHaveBeenCalledWith(newFileName); + expect(Editor.prototype.updateModelLanguage).toHaveBeenCalledWith(newFileName); }); }); + it('registers callback with editor onChangeContent', () => { + expect(Editor.prototype.onChangeContent).toHaveBeenCalledWith(expect.any(Function)); + }); + it('emits input event when the blob content is changed', () => { - const editorEl = wrapper.find({ ref: 'editor' }); - wrapper.vm.editor = { - getValue: jest.fn().mockReturnValue(value), - }; + expect(wrapper.emitted().input).toBeUndefined(); - editorEl.trigger('keyup'); + triggerChangeContent(value); - return nextTick().then(() => { - expect(wrapper.emitted().input[0]).toEqual([value]); - }); + expect(wrapper.emitted().input).toEqual([[value]]); }); }); }); diff --git a/spec/frontend/blob/components/blob_edit_header_spec.js b/spec/frontend/blob/components/blob_edit_header_spec.js index db7d7d7d48d..c71595a79cf 100644 --- a/spec/frontend/blob/components/blob_edit_header_spec.js +++ b/spec/frontend/blob/components/blob_edit_header_spec.js @@ -1,18 +1,21 @@ import { shallowMount } from '@vue/test-utils'; +import { GlFormInput, GlButton } from '@gitlab/ui'; import BlobEditHeader from '~/blob/components/blob_edit_header.vue'; -import { GlFormInput } from '@gitlab/ui'; describe('Blob Header Editing', () => { let wrapper; const value = 'foo.md'; - function createComponent() { + const createComponent = (props = {}) => { wrapper = shallowMount(BlobEditHeader, { propsData: { value, + ...props, }, }); - } + }; + const findDeleteButton = () => + wrapper.findAll(GlButton).wrappers.find(x => x.text() === 'Delete file'); beforeEach(() => { createComponent(); @@ -30,6 +33,10 @@ describe('Blob Header Editing', () => { it('contains a form input field', () => { expect(wrapper.contains(GlFormInput)).toBe(true); }); + + it('does not show delete button', () => { + expect(findDeleteButton()).toBeUndefined(); + }); }); describe('functionality', () => { @@ -47,4 +54,35 @@ describe('Blob Header Editing', () => { }); }); }); + + describe.each` + props | expectedDisabled + ${{ showDelete: true }} | ${false} + ${{ showDelete: true, canDelete: false }} | ${true} + `('with $props', ({ props, expectedDisabled }) => { + beforeEach(() => { + createComponent(props); + }); + + it(`shows delete button (disabled=${expectedDisabled})`, () => { + const deleteButton = findDeleteButton(); + + expect(deleteButton.exists()).toBe(true); + expect(deleteButton.props('disabled')).toBe(expectedDisabled); + }); + }); + + describe('with delete button', () => { + beforeEach(() => { + createComponent({ showDelete: true, canDelete: true }); + }); + + it('emits delete when clicked', () => { + expect(wrapper.emitted().delete).toBeUndefined(); + + findDeleteButton().vm.$emit('click'); + + expect(wrapper.emitted().delete).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/blob/components/blob_embeddable_spec.js b/spec/frontend/blob/components/blob_embeddable_spec.js index b2fe71f1401..1f6790013ca 100644 --- a/spec/frontend/blob/components/blob_embeddable_spec.js +++ b/spec/frontend/blob/components/blob_embeddable_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import { GlFormInputGroup } from '@gitlab/ui'; +import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; describe('Blob Embeddable', () => { let wrapper; diff --git a/spec/frontend/blob/components/blob_header_default_actions_spec.js b/spec/frontend/blob/components/blob_header_default_actions_spec.js index 529e7cc85f5..590e36b16af 100644 --- a/spec/frontend/blob/components/blob_header_default_actions_spec.js +++ b/spec/frontend/blob/components/blob_header_default_actions_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlButtonGroup, GlButton } from '@gitlab/ui'; import BlobHeaderActions from '~/blob/components/blob_header_default_actions.vue'; import { BTN_COPY_CONTENTS_TITLE, @@ -6,7 +7,6 @@ import { BTN_RAW_TITLE, RICH_BLOB_VIEWER, } from '~/blob/components/constants'; -import { GlButtonGroup, GlDeprecatedButton } from '@gitlab/ui'; import { Blob } from './mock_data'; describe('Blob Header Default Actions', () => { @@ -26,7 +26,7 @@ describe('Blob Header Default Actions', () => { beforeEach(() => { createComponent(); btnGroup = wrapper.find(GlButtonGroup); - buttons = wrapper.findAll(GlDeprecatedButton); + buttons = wrapper.findAll(GlButton); }); afterEach(() => { @@ -61,7 +61,7 @@ describe('Blob Header Default Actions', () => { createComponent({ activeViewer: RICH_BLOB_VIEWER, }); - buttons = wrapper.findAll(GlDeprecatedButton); + buttons = wrapper.findAll(GlButton); expect(buttons.at(0).attributes('disabled')).toBeTruthy(); }); diff --git a/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js b/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js index f1a7ac8b21a..cf1101bc22c 100644 --- a/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js +++ b/spec/frontend/blob/components/blob_header_viewer_switcher_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlButtonGroup, GlButton } from '@gitlab/ui'; import BlobHeaderViewerSwitcher from '~/blob/components/blob_header_viewer_switcher.vue'; import { RICH_BLOB_VIEWER, @@ -6,7 +7,6 @@ import { SIMPLE_BLOB_VIEWER, SIMPLE_BLOB_VIEWER_TITLE, } from '~/blob/components/constants'; -import { GlButtonGroup, GlButton } from '@gitlab/ui'; describe('Blob Header Viewer Switcher', () => { let wrapper; diff --git a/spec/frontend/blob/components/mock_data.js b/spec/frontend/blob/components/mock_data.js index 58aa1dc6dc9..8cfcec2693c 100644 --- a/spec/frontend/blob/components/mock_data.js +++ b/spec/frontend/blob/components/mock_data.js @@ -47,10 +47,12 @@ export const BinaryBlob = { }; export const RichBlobContentMock = { + path: 'foo.md', richData: '<h1>Rich</h1>', }; export const SimpleBlobContentMock = { + path: 'foo.js', plainData: 'Plain', }; diff --git a/spec/frontend/blob/notebook/notebook_viever_spec.js b/spec/frontend/blob/notebook/notebook_viever_spec.js index 535d2bd544a..f6a926a5ecb 100644 --- a/spec/frontend/blob/notebook/notebook_viever_spec.js +++ b/spec/frontend/blob/notebook/notebook_viever_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import component from '~/blob/notebook/notebook_viewer.vue'; import NotebookLab from '~/notebook/index.vue'; -import waitForPromises from 'helpers/wait_for_promises'; describe('iPython notebook renderer', () => { let wrapper; diff --git a/spec/frontend/blob/pipeline_tour_success_modal_spec.js b/spec/frontend/blob/pipeline_tour_success_modal_spec.js index 6d4e5e46cb8..9998cd7f91c 100644 --- a/spec/frontend/blob/pipeline_tour_success_modal_spec.js +++ b/spec/frontend/blob/pipeline_tour_success_modal_spec.js @@ -1,8 +1,8 @@ -import pipelineTourSuccess from '~/blob/pipeline_tour_success_modal.vue'; import { shallowMount } from '@vue/test-utils'; import Cookies from 'js-cookie'; -import { GlSprintf, GlModal } from '@gitlab/ui'; +import { GlSprintf, GlModal, GlLink } from '@gitlab/ui'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; +import pipelineTourSuccess from '~/blob/pipeline_tour_success_modal.vue'; import modalProps from './pipeline_tour_success_mock_data'; describe('PipelineTourSuccessModal', () => { @@ -18,6 +18,7 @@ describe('PipelineTourSuccessModal', () => { propsData: modalProps, stubs: { GlModal, + GlSprintf, }, }); @@ -37,6 +38,10 @@ describe('PipelineTourSuccessModal', () => { expect(sprintf.exists()).toBe(true); }); + it('renders the link for codeQualityLink', () => { + expect(wrapper.find(GlLink).attributes('href')).toBe(wrapper.vm.$options.codeQualityLink); + }); + it('calls to remove cookie', () => { wrapper.vm.disableModalFromRenderingAgain(); 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 3c03e6f04ab..4714d34dbec 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 @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import Popover from '~/blob/suggest_gitlab_ci_yml/components/popover.vue'; import { mockTracking, unmockTracking, triggerEvent } from 'helpers/tracking_helper'; +import { GlButton } from '@gitlab/ui'; +import Popover from '~/blob/suggest_gitlab_ci_yml/components/popover.vue'; import * as utils from '~/lib/utils/common_utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), @@ -96,7 +96,7 @@ describe('Suggest gitlab-ci.yml Popover', () => { const expectedAction = 'click_button'; const expectedProperty = 'owner'; const expectedValue = '10'; - const dismissButton = wrapper.find(GlDeprecatedButton); + const dismissButton = wrapper.find(GlButton); trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); triggerEvent(dismissButton.element); diff --git a/spec/frontend/blob/utils_spec.js b/spec/frontend/blob/utils_spec.js index 119ed2dfe7a..ab9e325e963 100644 --- a/spec/frontend/blob/utils_spec.js +++ b/spec/frontend/blob/utils_spec.js @@ -1,53 +1,44 @@ import Editor from '~/editor/editor_lite'; import * as utils from '~/blob/utils'; -const mockCreateMonacoInstance = jest.fn(); -jest.mock('~/editor/editor_lite', () => { - return jest.fn().mockImplementation(() => { - return { createInstance: mockCreateMonacoInstance }; - }); -}); +jest.mock('~/editor/editor_lite'); describe('Blob utilities', () => { - beforeEach(() => { - Editor.mockClear(); - }); - describe('initEditorLite', () => { let editorEl; const blobPath = 'foo.txt'; const blobContent = 'Foo bar'; + const blobGlobalId = 'snippet_777'; beforeEach(() => { - setFixtures('<div id="editor"></div>'); - editorEl = document.getElementById('editor'); + editorEl = document.createElement('div'); }); describe('Monaco editor', () => { it('initializes the Editor Lite', () => { utils.initEditorLite({ el: editorEl }); - expect(Editor).toHaveBeenCalled(); + expect(Editor).toHaveBeenCalledWith({ + scrollbar: { + alwaysConsumeMouseWheel: false, + }, + }); }); - it('creates the instance with the passed parameters', () => { - utils.initEditorLite({ el: editorEl }); - expect(mockCreateMonacoInstance.mock.calls[0]).toEqual([ - { + it.each([[{}], [{ blobPath, blobContent, blobGlobalId }]])( + 'creates the instance with the passed parameters %s', + extraParams => { + const params = { el: editorEl, - blobPath: undefined, - blobContent: undefined, - }, - ]); + ...extraParams, + }; - utils.initEditorLite({ el: editorEl, blobPath, blobContent }); - expect(mockCreateMonacoInstance.mock.calls[1]).toEqual([ - { - el: editorEl, - blobPath, - blobContent, - }, - ]); - }); + expect(Editor.prototype.createInstance).not.toHaveBeenCalled(); + + utils.initEditorLite(params); + + expect(Editor.prototype.createInstance).toHaveBeenCalledWith(params); + }, + ); }); }); }); diff --git a/spec/frontend/blob/viewer/index_spec.js b/spec/frontend/blob/viewer/index_spec.js index 7239f59c6fa..97ac42a10bf 100644 --- a/spec/frontend/blob/viewer/index_spec.js +++ b/spec/frontend/blob/viewer/index_spec.js @@ -24,11 +24,11 @@ describe('Blob viewer', () => { blob = new BlobViewer(); - mock.onGet('http://test.host/snippets/1.json?viewer=rich').reply(200, { + mock.onGet('http://test.host/-/snippets/1.json?viewer=rich').reply(200, { html: '<div>testing</div>', }); - mock.onGet('http://test.host/snippets/1.json?viewer=simple').reply(200, { + mock.onGet('http://test.host/-/snippets/1.json?viewer=simple').reply(200, { html: '<div>testing</div>', }); diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js index f5cd623ebce..98fa96de124 100644 --- a/spec/frontend/blob_edit/blob_bundle_spec.js +++ b/spec/frontend/blob_edit/blob_bundle_spec.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import blobBundle from '~/blob_edit/blob_bundle'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import blobBundle from '~/blob_edit/blob_bundle'; jest.mock('~/blob_edit/edit_blob'); diff --git a/spec/frontend/boards/board_card_spec.js b/spec/frontend/boards/board_card_spec.js index 959c71d05ca..d01b895f996 100644 --- a/spec/frontend/boards/board_card_spec.js +++ b/spec/frontend/boards/board_card_spec.js @@ -5,8 +5,8 @@ import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; import eventHub from '~/boards/eventhub'; import sidebarEventHub from '~/sidebar/event_hub'; diff --git a/spec/frontend/boards/components/board_column_spec.js b/spec/frontend/boards/components/board_column_spec.js index 6853fe2559d..c06b7aceaad 100644 --- a/spec/frontend/boards/components/board_column_spec.js +++ b/spec/frontend/boards/components/board_column_spec.js @@ -2,14 +2,13 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import { listObj } from 'jest/boards/mock_data'; import Board from '~/boards/components/board_column.vue'; import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; import axios from '~/lib/utils/axios_utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; - describe('Board Column Component', () => { let wrapper; let axiosMock; diff --git a/spec/frontend/boards/components/board_form_spec.js b/spec/frontend/boards/components/board_form_spec.js index 94f607698d7..b1d277863e8 100644 --- a/spec/frontend/boards/components/board_form_spec.js +++ b/spec/frontend/boards/components/board_form_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import boardsStore from '~/boards/stores/boards_store'; import boardForm from '~/boards/components/board_form.vue'; import DeprecatedModal from '~/vue_shared/components/deprecated_modal.vue'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('board_form.vue', () => { let wrapper; diff --git a/spec/frontend/boards/components/board_list_header_spec.js b/spec/frontend/boards/components/board_list_header_spec.js index 95673da1c56..76a3d5e71c8 100644 --- a/spec/frontend/boards/components/board_list_header_spec.js +++ b/spec/frontend/boards/components/board_list_header_spec.js @@ -2,14 +2,13 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import { listObj } from 'jest/boards/mock_data'; import BoardListHeader from '~/boards/components/board_list_header.vue'; import List from '~/boards/models/list'; import { ListType } from '~/boards/constants'; import axios from '~/lib/utils/axios_utils'; -import { TEST_HOST } from 'helpers/test_constants'; -import { listObj } from 'jest/boards/mock_data'; - describe('Board List Header Component', () => { let wrapper; let axiosMock; diff --git a/spec/frontend/boards/components/board_settings_sidebar_spec.js b/spec/frontend/boards/components/board_settings_sidebar_spec.js new file mode 100644 index 00000000000..f39adc0fc49 --- /dev/null +++ b/spec/frontend/boards/components/board_settings_sidebar_spec.js @@ -0,0 +1,159 @@ +import '~/boards/models/list'; +import MockAdapter from 'axios-mock-adapter'; +import axios from 'axios'; +import Vuex from 'vuex'; +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 sidebarEventHub from '~/sidebar/event_hub'; +import { inactiveId } from '~/boards/constants'; + +const localVue = createLocalVue(); + +localVue.use(Vuex); + +describe('BoardSettingsSidebar', () => { + let wrapper; + let mock; + let storeActions; + 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, + }); + + wrapper = shallowMount(BoardSettingsSidebar, { + store, + localVue, + }); + }; + const findLabel = () => wrapper.find(GlLabel); + const findDrawer = () => wrapper.find(GlDrawer); + + beforeEach(() => { + boardsStore.create(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + wrapper.destroy(); + }); + + it('finds a GlDrawer component', () => { + createComponent(); + + expect(findDrawer().exists()).toBe(true); + }); + + describe('on close', () => { + it('calls closeSidebar', async () => { + const spy = jest.fn(); + createComponent({ activeId: inactiveId }, { setActiveId: spy }); + + findDrawer().vm.$emit('close'); + + await wrapper.vm.$nextTick(); + + expect(storeActions.setActiveId).toHaveBeenCalledWith( + expect.anything(), + inactiveId, + undefined, + ); + }); + + it('calls closeSidebar on sidebar.closeAll event', async () => { + createComponent({ activeId: inactiveId }, { setActiveId: jest.fn() }); + + sidebarEventHub.$emit('sidebar.closeAll'); + + await wrapper.vm.$nextTick(); + + expect(storeActions.setActiveId).toHaveBeenCalledWith( + expect.anything(), + inactiveId, + undefined, + ); + }); + }); + + describe('when activeId is zero', () => { + it('renders GlDrawer with open false', () => { + createComponent(); + + expect(findDrawer().props('open')).toBe(false); + }); + }); + + describe('when activeId is greater than zero', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + boardsStore.addList({ + id: listId, + label: { title: labelTitle, color: labelColor }, + list_type: 'label', + }); + }); + + afterEach(() => { + boardsStore.removeList(listId); + }); + + it('renders GlDrawer with open false', () => { + createComponent({ activeId: 1 }); + + expect(findDrawer().props('open')).toBe(true); + }); + }); + + describe('when activeId is in boardsStore', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + boardsStore.addList({ + id: listId, + label: { title: labelTitle, color: labelColor }, + list_type: 'label', + }); + + createComponent({ activeId: listId }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('renders label title', () => { + expect(findLabel().props('title')).toBe(labelTitle); + }); + + it('renders label background color', () => { + expect(findLabel().props('backgroundColor')).toBe(labelColor); + }); + }); + + describe('when activeId is not in boardsStore', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + + boardsStore.addList({ id: listId, label: { title: labelTitle, color: labelColor } }); + + createComponent({ activeId: inactiveId }); + }); + + afterEach(() => { + mock.restore(); + }); + + it('does not render GlLabel', () => { + expect(findLabel().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/boards/components/boards_selector_spec.js b/spec/frontend/boards/components/boards_selector_spec.js index b1ae86c2d3f..f2d4de238d1 100644 --- a/spec/frontend/boards/components/boards_selector_spec.js +++ b/spec/frontend/boards/components/boards_selector_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; -import { GlDropdown, GlLoadingIcon } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlLoadingIcon } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import BoardsSelector from '~/boards/components/boards_selector.vue'; import boardsStore from '~/boards/stores/boards_store'; @@ -103,7 +103,7 @@ describe('BoardsSelector', () => { }); // Emits gl-dropdown show event to simulate the dropdown is opened at initialization time - wrapper.find(GlDropdown).vm.$emit('show'); + wrapper.find(GlDeprecatedDropdown).vm.$emit('show'); }); afterEach(() => { diff --git a/spec/frontend/boards/components/sidebar/remove_issue_spec.js b/spec/frontend/boards/components/sidebar/remove_issue_spec.js new file mode 100644 index 00000000000..a33e4046724 --- /dev/null +++ b/spec/frontend/boards/components/sidebar/remove_issue_spec.js @@ -0,0 +1,28 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; + +import RemoveIssue from '~/boards/components/sidebar/remove_issue.vue'; + +describe('boards sidebar remove issue', () => { + let wrapper; + + const findButton = () => wrapper.find(GlButton); + + const createComponent = propsData => { + wrapper = shallowMount(RemoveIssue, { + propsData: { + issue: {}, + list: {}, + ...propsData, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders remove button', () => { + expect(findButton().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/boards/issue_card_spec.js b/spec/frontend/boards/issue_card_spec.js index 15750a161ae..dee8cb7b6e5 100644 --- a/spec/frontend/boards/issue_card_spec.js +++ b/spec/frontend/boards/issue_card_spec.js @@ -5,10 +5,10 @@ import '~/boards/models/label'; import '~/boards/models/assignee'; import '~/boards/models/issue'; import '~/boards/models/list'; +import { GlLabel } from '@gitlab/ui'; import IssueCardInner from '~/boards/components/issue_card_inner.vue'; import { listObj } from './mock_data'; import store from '~/boards/stores'; -import { GlLabel } from '@gitlab/ui'; describe('Issue card component', () => { const user = new ListAssignee({ diff --git a/spec/frontend/boards/issue_spec.js b/spec/frontend/boards/issue_spec.js index 412f20684f5..d68e17c06a7 100644 --- a/spec/frontend/boards/issue_spec.js +++ b/spec/frontend/boards/issue_spec.js @@ -5,7 +5,7 @@ import '~/boards/models/assignee'; import '~/boards/models/issue'; import '~/boards/models/list'; import boardsStore from '~/boards/stores/boards_store'; -import { setMockEndpoints } from './mock_data'; +import { setMockEndpoints, mockIssue } from './mock_data'; describe('Issue model', () => { let issue; @@ -14,28 +14,7 @@ describe('Issue model', () => { setMockEndpoints(); boardsStore.create(); - issue = new ListIssue({ - title: 'Testing', - id: 1, - iid: 1, - confidential: false, - labels: [ - { - id: 1, - title: 'test', - color: 'red', - description: 'testing', - }, - ], - assignees: [ - { - id: 1, - name: 'name', - username: 'username', - avatar_url: 'http://avatar_url', - }, - ], - }); + issue = new ListIssue(mockIssue); }); it('has label', () => { diff --git a/spec/frontend/boards/list_spec.js b/spec/frontend/boards/list_spec.js index b30281f8df5..b731bb6e474 100644 --- a/spec/frontend/boards/list_spec.js +++ b/spec/frontend/boards/list_spec.js @@ -4,6 +4,7 @@ /* global ListLabel */ 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'; @@ -11,7 +12,6 @@ import '~/boards/models/issue'; import '~/boards/models/list'; import { ListType } from '~/boards/constants'; import boardsStore from '~/boards/stores/boards_store'; -import waitForPromises from 'helpers/wait_for_promises'; import { listObj, listObjDuplicate, boardsMockInterceptor } from './mock_data'; describe('List model', () => { diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 97d49de6f2e..8ef6efe23c7 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -92,6 +92,29 @@ export const mockMilestone = { due_date: '2019-12-31', }; +export const mockIssue = { + title: 'Testing', + id: 1, + iid: 1, + confidential: false, + labels: [ + { + id: 1, + title: 'test', + color: 'red', + description: 'testing', + }, + ], + assignees: [ + { + id: 1, + name: 'name', + username: 'username', + avatar_url: 'http://avatar_url', + }, + ], +}; + export const BoardsMockData = { GET: { '/test/-/boards/1/lists/300/issues?id=300&page=1': { diff --git a/spec/frontend/boards/stores/actions_spec.js b/spec/frontend/boards/stores/actions_spec.js index 0debca1310a..d539cba76ca 100644 --- a/spec/frontend/boards/stores/actions_spec.js +++ b/spec/frontend/boards/stores/actions_spec.js @@ -1,6 +1,7 @@ +import testAction from 'helpers/vuex_action_helper'; import actions from '~/boards/stores/actions'; import * as types from '~/boards/stores/mutation_types'; -import testAction from 'helpers/vuex_action_helper'; +import { inactiveId } from '~/boards/constants'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -8,19 +9,36 @@ const expectNotImplemented = action => { }); }; -describe('setEndpoints', () => { - it('sets endpoints object', () => { - const mockEndpoints = { +describe('setInitialBoardData', () => { + it('sets data object', () => { + const mockData = { foo: 'bar', bar: 'baz', }; return testAction( - actions.setEndpoints, - mockEndpoints, + actions.setInitialBoardData, + mockData, {}, - [{ type: types.SET_ENDPOINTS, payload: mockEndpoints }], + [{ type: types.SET_INITIAL_BOARD_DATA, payload: mockData }], + [], + ); + }); +}); + +describe('setActiveId', () => { + it('should commit mutation SET_ACTIVE_ID', done => { + const state = { + activeId: inactiveId, + }; + + testAction( + actions.setActiveId, + 1, + state, + [{ type: types.SET_ACTIVE_ID, payload: 1 }], [], + done, ); }); }); diff --git a/spec/frontend/boards/stores/mutations_spec.js b/spec/frontend/boards/stores/mutations_spec.js index bc57c30b354..c1f7f3dda6e 100644 --- a/spec/frontend/boards/stores/mutations_spec.js +++ b/spec/frontend/boards/stores/mutations_spec.js @@ -1,6 +1,6 @@ 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'; const expectNotImplemented = action => { it('is not implemented', () => { @@ -15,7 +15,7 @@ describe('Board Store Mutations', () => { state = defaultState(); }); - describe('SET_ENDPOINTS', () => { + describe('SET_INITIAL_BOARD_DATA', () => { it('Should set initial Boards data to state', () => { const endpoints = { boardsEndpoint: '/boards/', @@ -25,10 +25,22 @@ describe('Board Store Mutations', () => { boardId: 1, fullPath: 'gitlab-org', }; + const boardType = 'group'; - mutations[types.SET_ENDPOINTS](state, endpoints); + mutations.SET_INITIAL_BOARD_DATA(state, { ...endpoints, boardType }); expect(state.endpoints).toEqual(endpoints); + expect(state.boardType).toEqual(boardType); + }); + }); + + describe('SET_ACTIVE_ID', () => { + it('updates activeListId to be the value that is passed', () => { + const expectedId = 1; + + mutations.SET_ACTIVE_ID(state, expectedId); + + expect(state.activeId).toBe(expectedId); }); }); @@ -68,6 +80,35 @@ describe('Board Store Mutations', () => { expectNotImplemented(mutations.RECEIVE_REMOVE_LIST_ERROR); }); + describe('REQUEST_ISSUES_FOR_ALL_LISTS', () => { + it('sets isLoadingIssues to true', () => { + expect(state.isLoadingIssues).toBe(false); + + mutations.REQUEST_ISSUES_FOR_ALL_LISTS(state); + + expect(state.isLoadingIssues).toBe(true); + }); + }); + + describe('RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS', () => { + it('sets isLoadingIssues to false and updates issuesByListId object', () => { + const listIssues = { + '1': [mockIssue], + }; + + state = { + ...state, + isLoadingIssues: true, + issuesByListId: {}, + }; + + mutations.RECEIVE_ISSUES_FOR_ALL_LISTS_SUCCESS(state, listIssues); + + expect(state.isLoadingIssues).toBe(false); + expect(state.issuesByListId).toEqual(listIssues); + }); + }); + describe('REQUEST_ADD_ISSUE', () => { expectNotImplemented(mutations.REQUEST_ADD_ISSUE); }); diff --git a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap index c9948db95f8..261c406171e 100644 --- a/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap +++ b/spec/frontend/branches/components/__snapshots__/divergence_graph_spec.js.snap @@ -12,7 +12,7 @@ exports[`Branch divergence graph component renders ahead and behind count 1`] = /> <div - class="graph-separator pull-left mt-1" + class="graph-separator float-left mt-1" /> <graph-bar-stub diff --git a/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js b/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js index a52b38599f7..7785d436834 100644 --- a/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_enviroments_dropdown_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; -import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; +import CiEnvironmentsDropdown from '~/ci_variable_list/components/ci_environments_dropdown.vue'; const localVue = createLocalVue(); localVue.use(Vuex); @@ -26,8 +26,8 @@ describe('Ci environments dropdown', () => { }); }; - const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); - const findDropdownItemByIndex = index => wrapper.findAll(GlDropdownItem).at(index); + const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); + const findDropdownItemByIndex = index => wrapper.findAll(GlDeprecatedDropdownItem).at(index); const findActiveIconByIndex = index => wrapper.findAll(GlIcon).at(index); afterEach(() => { 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 ad398d6ccd6..4e35243f484 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 @@ -1,6 +1,6 @@ import Vuex from 'vuex'; import { createLocalVue, shallowMount, mount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlFormCombobox } from '@gitlab/ui'; +import { GlButton, GlFormCombobox } from '@gitlab/ui'; import { AWS_ACCESS_KEY_ID } from '~/ci_variable_list/constants'; import CiVariableModal from '~/ci_variable_list/components/ci_variable_modal.vue'; import createStore from '~/ci_variable_list/store'; @@ -29,14 +29,14 @@ describe('Ci variable modal', () => { }; const findModal = () => wrapper.find(ModalStub); - const addOrUpdateButton = index => + const findAddorUpdateButton = () => findModal() - .findAll(GlDeprecatedButton) - .at(index); + .findAll(GlButton) + .wrappers.find(button => button.props('variant') === 'success'); const deleteVariableButton = () => findModal() - .findAll(GlDeprecatedButton) - .at(1); + .findAll(GlButton) + .wrappers.find(button => button.props('variant') === 'danger'); afterEach(() => { wrapper.destroy(); @@ -69,7 +69,7 @@ describe('Ci variable modal', () => { }); it('button is disabled when no key/value pair are present', () => { - expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy(); + expect(findAddorUpdateButton().attributes('disabled')).toBeTruthy(); }); }); @@ -82,11 +82,11 @@ describe('Ci variable modal', () => { }); it('button is enabled when key/value pair are present', () => { - expect(addOrUpdateButton(1).attributes('disabled')).toBeFalsy(); + expect(findAddorUpdateButton().attributes('disabled')).toBeFalsy(); }); it('Add variable button dispatches addVariable action', () => { - addOrUpdateButton(1).vm.$emit('click'); + findAddorUpdateButton().vm.$emit('click'); expect(store.dispatch).toHaveBeenCalledWith('addVariable'); }); @@ -152,11 +152,11 @@ describe('Ci variable modal', () => { }); it('button text is Update variable when updating', () => { - expect(addOrUpdateButton(2).text()).toBe('Update variable'); + expect(findAddorUpdateButton().text()).toBe('Update variable'); }); it('Update variable button dispatches updateVariable with correct variable', () => { - addOrUpdateButton(2).vm.$emit('click'); + findAddorUpdateButton().vm.$emit('click'); expect(store.dispatch).toHaveBeenCalledWith('updateVariable'); }); @@ -189,7 +189,7 @@ describe('Ci variable modal', () => { }); it('disables the submit button', () => { - expect(addOrUpdateButton(1).attributes('disabled')).toBeTruthy(); + expect(findAddorUpdateButton().attributes('disabled')).toBeTruthy(); }); it('shows the correct error text', () => { @@ -213,7 +213,7 @@ describe('Ci variable modal', () => { }); it('does not disable the submit button', () => { - expect(addOrUpdateButton(1).attributes('disabled')).toBeFalsy(); + expect(findAddorUpdateButton().attributes('disabled')).toBeFalsy(); }); }); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_popover_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_popover_spec.js index 46f77a6f11e..5d37f059161 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_popover_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_popover_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import CiVariablePopover from '~/ci_variable_list/components/ci_variable_popover.vue'; import mockData from '../services/mock_data'; @@ -18,7 +18,7 @@ describe('Ci Variable Popover', () => { }); }; - const findButton = () => wrapper.find(GlDeprecatedButton); + const findButton = () => wrapper.find(GlButton); beforeEach(() => { createComponent(); diff --git a/spec/frontend/ci_variable_list/store/actions_spec.js b/spec/frontend/ci_variable_list/store/actions_spec.js index eb565d4c979..4b89e467df0 100644 --- a/spec/frontend/ci_variable_list/store/actions_spec.js +++ b/spec/frontend/ci_variable_list/store/actions_spec.js @@ -1,8 +1,8 @@ -import Api from '~/api'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import getInitialState from '~/ci_variable_list/store/state'; import * as actions from '~/ci_variable_list/store/actions'; import * as types from '~/ci_variable_list/store/mutation_types'; diff --git a/spec/frontend/clusters/clusters_bundle_spec.js b/spec/frontend/clusters/clusters_bundle_spec.js index a9870e4db57..d3277cdb7cc 100644 --- a/spec/frontend/clusters/clusters_bundle_spec.js +++ b/spec/frontend/clusters/clusters_bundle_spec.js @@ -1,14 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; import { loadHTMLFixture } from 'helpers/fixtures'; import { setTestTimeout } from 'helpers/timeout'; import Clusters from '~/clusters/clusters_bundle'; -import { - APPLICATION_STATUS, - INGRESS_DOMAIN_SUFFIX, - APPLICATIONS, - RUNNER, -} from '~/clusters/constants'; +import { APPLICATION_STATUS, APPLICATIONS, RUNNER } from '~/clusters/constants'; import axios from '~/lib/utils/axios_utils'; import initProjectSelectDropdown from '~/project_select'; @@ -63,25 +57,6 @@ describe('Clusters', () => { }); }); - describe('toggle', () => { - it('should update the button and the input field on click', done => { - const toggleButton = document.querySelector( - '.js-cluster-enable-toggle-area .js-project-feature-toggle', - ); - const toggleInput = document.querySelector( - '.js-cluster-enable-toggle-area .js-project-feature-toggle-input', - ); - - $(toggleInput).one('trigger-change', () => { - expect(toggleButton.classList).not.toContain('is-checked'); - expect(toggleInput.getAttribute('value')).toEqual('false'); - done(); - }); - - toggleButton.click(); - }); - }); - describe('checkForNewInstalls', () => { const INITIAL_APP_MAP = { helm: { status: null, title: 'Helm Tiller' }, @@ -328,7 +303,6 @@ describe('Clusters', () => { return promise.then(() => { expect(cluster.store.state.applications.helm.status).toEqual(INSTALLED); expect(cluster.store.state.applications.helm.uninstallFailed).toBe(true); - expect(cluster.store.state.applications.helm.requestReason).toBeDefined(); }); }); @@ -354,10 +328,8 @@ describe('Clusters', () => { describe('handleClusterStatusSuccess', () => { beforeEach(() => { jest.spyOn(cluster.store, 'updateStateFromServer').mockReturnThis(); - jest.spyOn(cluster, 'toggleIngressDomainHelpText').mockReturnThis(); jest.spyOn(cluster, 'checkForNewInstalls').mockReturnThis(); jest.spyOn(cluster, 'updateContainer').mockReturnThis(); - cluster.handleClusterStatusSuccess({ data: {} }); }); @@ -369,53 +341,11 @@ describe('Clusters', () => { expect(cluster.checkForNewInstalls).toHaveBeenCalled(); }); - it('toggles ingress domain help text', () => { - expect(cluster.toggleIngressDomainHelpText).toHaveBeenCalled(); - }); - it('updates message containers', () => { expect(cluster.updateContainer).toHaveBeenCalled(); }); }); - describe('toggleIngressDomainHelpText', () => { - let ingressPreviousState; - let ingressNewState; - - beforeEach(() => { - ingressPreviousState = { externalIp: null }; - ingressNewState = { externalIp: '127.0.0.1' }; - }); - - describe(`when ingress have an external ip assigned`, () => { - beforeEach(() => { - cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState); - }); - - it('displays custom domain help text', () => { - expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(false); - }); - - it('updates ingress external ip address', () => { - expect(cluster.ingressDomainSnippet.textContent).toEqual( - `${ingressNewState.externalIp}${INGRESS_DOMAIN_SUFFIX}`, - ); - }); - }); - - describe(`when ingress does not have an external ip assigned`, () => { - it('hides custom domain help text', () => { - ingressPreviousState.externalIp = '127.0.0.1'; - ingressNewState.externalIp = null; - cluster.ingressDomainHelpText.classList.remove('hide'); - - cluster.toggleIngressDomainHelpText(ingressPreviousState, ingressNewState); - - expect(cluster.ingressDomainHelpText.classList.contains('hide')).toEqual(true); - }); - }); - }); - describe('updateApplication', () => { const params = { version: '1.0.0' }; let storeUpdateApplication; diff --git a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap index 92237590550..3328ec724fd 100644 --- a/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/applications_spec.js.snap @@ -17,6 +17,22 @@ exports[`Applications Cert-Manager application shows the correct description 1`] </p> `; +exports[`Applications Cilium application shows the correct description 1`] = ` +<p + data-testid="ciliumDescription" +> + Protect your clusters with GitLab Container Network Policies by enforcing how pods communicate with each other and other network endpoints. + <a + class="gl-link" + href="cilium-help-path" + rel="noopener" + target="_blank" + > + Learn more about configuring Network Policies here. + </a> +</p> +`; + exports[`Applications Crossplane application shows the correct description 1`] = ` <p data-testid="crossplaneDescription" diff --git a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap index d4269bf14ba..93b757e008a 100644 --- a/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap +++ b/spec/frontend/clusters/components/__snapshots__/remove_cluster_confirmation_spec.js.snap @@ -1,7 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Remove cluster confirmation modal renders splitbutton with modal included 1`] = ` -<div> +<div + class="gl-display-flex gl-justify-content-end" +> <div class="dropdown b-dropdown gl-dropdown btn-group" > diff --git a/spec/frontend/clusters/components/application_row_spec.js b/spec/frontend/clusters/components/application_row_spec.js index 94bdd7b7778..b97d4dbf355 100644 --- a/spec/frontend/clusters/components/application_row_spec.js +++ b/spec/frontend/clusters/components/application_row_spec.js @@ -83,6 +83,12 @@ describe('Application Row', () => { checkButtonState('Installing', true, true); }); + it('has disabled "Install" when APPLICATION_STATUS.UNINSTALLED', () => { + mountComponent({ status: APPLICATION_STATUS.UNINSTALLED }); + + checkButtonState('Install', false, true); + }); + it('has disabled "Installed" when application is installed and not uninstallable', () => { mountComponent({ status: APPLICATION_STATUS.INSTALLED, @@ -112,6 +118,15 @@ describe('Application Row', () => { checkButtonState('Install', false, false); }); + it('has disabled "Install" when installation disabled', () => { + mountComponent({ + status: APPLICATION_STATUS.INSTALLABLE, + installable: false, + }); + + checkButtonState('Install', false, true); + }); + it('has enabled "Install" when REQUEST_FAILURE (so you can try installing again)', () => { mountComponent({ status: APPLICATION_STATUS.INSTALLABLE }); diff --git a/spec/frontend/clusters/components/applications_spec.js b/spec/frontend/clusters/components/applications_spec.js index 7fc771201c1..e0ccf36e868 100644 --- a/spec/frontend/clusters/components/applications_spec.js +++ b/spec/frontend/clusters/components/applications_spec.js @@ -14,10 +14,9 @@ describe('Applications', () => { beforeEach(() => { gon.features = gon.features || {}; - gon.features.managedAppsLocalTiller = false; }); - const createApp = ({ applications, type } = {}, isShallow) => { + const createApp = ({ applications, type, props } = {}, isShallow) => { const mountMethod = isShallow ? shallowMount : mount; wrapper = mountMethod(Applications, { @@ -25,6 +24,7 @@ describe('Applications', () => { propsData: { type, applications: { ...APPLICATIONS_MOCK_STATE, ...applications }, + ...props, }, }); }; @@ -40,10 +40,6 @@ describe('Applications', () => { createApp({ type: CLUSTER_TYPE.PROJECT }); }); - it('renders a row for Helm Tiller', () => { - expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(true); - }); - it('renders a row for Ingress', () => { expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); }); @@ -79,6 +75,9 @@ describe('Applications', () => { it('renders a row for Fluentd', () => { expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); }); + it('renders a row for Cilium', () => { + expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); + }); }); describe('Group cluster applications', () => { @@ -86,10 +85,6 @@ describe('Applications', () => { createApp({ type: CLUSTER_TYPE.GROUP }); }); - it('renders a row for Helm Tiller', () => { - expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(true); - }); - it('renders a row for Ingress', () => { expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); }); @@ -125,6 +120,10 @@ describe('Applications', () => { it('renders a row for Fluentd', () => { expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); }); + + it('renders a row for Cilium', () => { + expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); + }); }); describe('Instance cluster applications', () => { @@ -132,10 +131,6 @@ describe('Applications', () => { createApp({ type: CLUSTER_TYPE.INSTANCE }); }); - it('renders a row for Helm Tiller', () => { - expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(true); - }); - it('renders a row for Ingress', () => { expect(wrapper.find('.js-cluster-application-row-ingress').exists()).toBe(true); }); @@ -171,18 +166,16 @@ describe('Applications', () => { it('renders a row for Fluentd', () => { expect(wrapper.find('.js-cluster-application-row-fluentd').exists()).toBe(true); }); + + it('renders a row for Cilium', () => { + expect(wrapper.find('.js-cluster-application-row-cilium').exists()).toBe(true); + }); }); describe('Helm application', () => { - describe('when managedAppsLocalTiller enabled', () => { - beforeEach(() => { - gon.features.managedAppsLocalTiller = true; - }); - - it('does not render a row for Helm Tiller', () => { - createApp(); - expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(false); - }); + it('does not render a row for Helm Tiller', () => { + createApp(); + expect(wrapper.find('.js-cluster-application-row-helm').exists()).toBe(false); }); }); @@ -240,7 +233,6 @@ describe('Applications', () => { externalHostname: 'localhost.localdomain', modsecurity_enabled: false, }, - helm: { title: 'Helm Tiller' }, cert_manager: { title: 'Cert-Manager' }, crossplane: { title: 'Crossplane', stack: '' }, runner: { title: 'GitLab Runner' }, @@ -249,6 +241,7 @@ describe('Applications', () => { knative: { title: 'Knative', hostname: '' }, elastic_stack: { title: 'Elastic Stack' }, fluentd: { title: 'Fluentd' }, + cilium: { title: 'GitLab Container Network Policies' }, }, }); @@ -365,7 +358,11 @@ describe('Applications', () => { it('renders readonly input', () => { createApp({ applications: { - ingress: { title: 'Ingress', status: 'installed', externalIp: '1.1.1.1' }, + ingress: { + title: 'Ingress', + status: 'installed', + externalIp: '1.1.1.1', + }, jupyter: { title: 'JupyterHub', status: 'installed', hostname: '' }, }, }); @@ -386,14 +383,6 @@ describe('Applications', () => { false, ); }); - - it('renders disabled install button', () => { - expect( - wrapper - .find('.js-cluster-application-row-jupyter .js-cluster-application-install-button') - .attributes('disabled'), - ).toEqual('disabled'); - }); }); }); @@ -513,7 +502,7 @@ describe('Applications', () => { describe('Elastic Stack application', () => { describe('with elastic stack installable', () => { - it('renders hostname active input', () => { + it('renders the install button enabled', () => { createApp(); expect( @@ -522,7 +511,7 @@ describe('Applications', () => { '.js-cluster-application-row-elastic_stack .js-cluster-application-install-button', ) .attributes('disabled'), - ).toEqual('disabled'); + ).toBeUndefined(); }); }); @@ -552,4 +541,11 @@ describe('Applications', () => { expect(wrapper.find(FluentdOutputSettings).exists()).toBe(true); }); }); + + describe('Cilium application', () => { + it('shows the correct description', () => { + createApp({ props: { ciliumHelpPath: 'cilium-help-path' } }); + expect(findByTestId('ciliumDescription').element).toMatchSnapshot(); + }); + }); }); diff --git a/spec/frontend/clusters/components/fluentd_output_settings_spec.js b/spec/frontend/clusters/components/fluentd_output_settings_spec.js index f03f2535947..0bc4eb73bf9 100644 --- a/spec/frontend/clusters/components/fluentd_output_settings_spec.js +++ b/spec/frontend/clusters/components/fluentd_output_settings_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlDeprecatedDropdown, GlFormCheckbox } from '@gitlab/ui'; import FluentdOutputSettings from '~/clusters/components/fluentd_output_settings.vue'; import { APPLICATION_STATUS, FLUENTD } from '~/clusters/constants'; -import { GlAlert, GlDropdown, GlFormCheckbox } from '@gitlab/ui'; import eventHub from '~/clusters/event_hub'; const { UPDATING } = APPLICATION_STATUS; @@ -36,7 +36,7 @@ describe('FluentdOutputSettings', () => { }; const findSaveButton = () => wrapper.find({ ref: 'saveBtn' }); const findCancelButton = () => wrapper.find({ ref: 'cancelBtn' }); - const findProtocolDropdown = () => wrapper.find(GlDropdown); + const findProtocolDropdown = () => wrapper.find(GlDeprecatedDropdown); const findCheckbox = name => wrapper.findAll(GlFormCheckbox).wrappers.find(x => x.text() === name); const findHost = () => wrapper.find('#fluentd-host'); diff --git a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js index 683f2e5c35a..3a9a608b2e2 100644 --- a/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js +++ b/spec/frontend/clusters/components/ingress_modsecurity_settings_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import { GlAlert, GlToggle, GlDeprecatedDropdown } from '@gitlab/ui'; import IngressModsecuritySettings from '~/clusters/components/ingress_modsecurity_settings.vue'; import { APPLICATION_STATUS, INGRESS } from '~/clusters/constants'; -import { GlAlert, GlToggle, GlDropdown } from '@gitlab/ui'; import eventHub from '~/clusters/event_hub'; const { UPDATING } = APPLICATION_STATUS; @@ -31,7 +31,7 @@ describe('IngressModsecuritySettings', () => { const findSaveButton = () => wrapper.find('.btn-success'); const findCancelButton = () => wrapper.find('[variant="secondary"]'); const findModSecurityToggle = () => wrapper.find(GlToggle); - const findModSecurityDropdown = () => wrapper.find(GlDropdown); + const findModSecurityDropdown = () => wrapper.find(GlDeprecatedDropdown); describe('when ingress is installed', () => { beforeEach(() => { diff --git a/spec/frontend/clusters/components/knative_domain_editor_spec.js b/spec/frontend/clusters/components/knative_domain_editor_spec.js index 73d08661199..a07258dcc69 100644 --- a/spec/frontend/clusters/components/knative_domain_editor_spec.js +++ b/spec/frontend/clusters/components/knative_domain_editor_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem } 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'; @@ -113,7 +113,7 @@ describe('KnativeDomainEditor', () => { createComponent({ knative: { ...knative, availableDomains: [newDomain] } }); jest.spyOn(wrapper.vm, 'selectDomain'); - wrapper.find(GlDropdownItem).vm.$emit('click'); + wrapper.find(GlDeprecatedDropdownItem).vm.$emit('click'); return wrapper.vm.$nextTick().then(() => { expect(wrapper.vm.selectDomain).toHaveBeenCalledWith(newDomain); diff --git a/spec/frontend/clusters/forms/components/integration_form_spec.js b/spec/frontend/clusters/forms/components/integration_form_spec.js new file mode 100644 index 00000000000..3a3700eb0b7 --- /dev/null +++ b/spec/frontend/clusters/forms/components/integration_form_spec.js @@ -0,0 +1,112 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlToggle, GlButton } from '@gitlab/ui'; +import IntegrationForm from '~/clusters/forms/components/integration_form.vue'; +import { createStore } from '~/clusters/forms/stores/index'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ClusterIntegrationForm', () => { + let wrapper; + + const defaultStoreValues = { + enabled: true, + editable: true, + environmentScope: '*', + baseDomain: 'testDomain', + applicationIngressExternalIp: null, + }; + + const createWrapper = (storeValues = defaultStoreValues) => { + wrapper = shallowMount(IntegrationForm, { + localVue, + store: createStore(storeValues), + provide: { + autoDevopsHelpPath: 'topics/autodevops/index', + externalEndpointHelpPath: 'user/clusters/applications.md', + }, + }); + }; + + const destroyWrapper = () => { + wrapper.destroy(); + wrapper = null; + }; + + const findSubmitButton = () => wrapper.find(GlButton); + const findGlToggle = () => wrapper.find(GlToggle); + + afterEach(() => { + destroyWrapper(); + }); + + describe('rendering', () => { + beforeEach(() => createWrapper()); + + it('enables toggle if editable is true', () => { + expect(findGlToggle().props('disabled')).toBe(false); + }); + it('sets the envScope to default', () => { + expect(wrapper.find('[id="cluster_environment_scope"]').attributes('value')).toBe('*'); + }); + + it('sets the baseDomain to default', () => { + expect(wrapper.find('[id="cluster_base_domain"]').attributes('value')).toBe('testDomain'); + }); + + describe('when editable is false', () => { + beforeEach(() => { + createWrapper({ ...defaultStoreValues, editable: false }); + }); + + it('disables toggle if editable is false', () => { + expect(findGlToggle().props('disabled')).toBe(true); + }); + + it('does not render the save button', () => { + expect(findSubmitButton().exists()).toBe(false); + }); + }); + + it('does not render external IP block if applicationIngressExternalIp was not passed', () => { + createWrapper({ ...defaultStoreValues }); + + expect(wrapper.find('.js-ingress-domain-help-text').exists()).toBe(false); + }); + + it('renders external IP block if applicationIngressExternalIp was passed', () => { + createWrapper({ ...defaultStoreValues, applicationIngressExternalIp: '127.0.0.1' }); + + expect(wrapper.find('.js-ingress-domain-help-text').exists()).toBe(true); + }); + }); + + describe('reactivity', () => { + beforeEach(() => createWrapper()); + + it('enables the submit button on changing toggle to different value', () => { + return wrapper.vm + .$nextTick() + .then(() => { + // setData is a bad approach because it changes the internal implementation which we should not touch + // but our GlFormInput lacks the ability to set a new value. + wrapper.setData({ toggleEnabled: !defaultStoreValues.enabled }); + }) + .then(() => { + expect(findSubmitButton().props('disabled')).toBe(false); + }); + }); + + it('enables the submit button on changing input values', () => { + return wrapper.vm + .$nextTick() + .then(() => { + wrapper.setData({ envScope: `${defaultStoreValues.environmentScope}1` }); + }) + .then(() => { + expect(findSubmitButton().props('disabled')).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/clusters/services/application_state_machine_spec.js b/spec/frontend/clusters/services/application_state_machine_spec.js index b27cd2c80fd..7eee54949fa 100644 --- a/spec/frontend/clusters/services/application_state_machine_spec.js +++ b/spec/frontend/clusters/services/application_state_machine_spec.js @@ -19,6 +19,7 @@ const { UPDATE_ERRORED, UNINSTALLING, UNINSTALL_ERRORED, + UNINSTALLED, } = APPLICATION_STATUS; const NO_EFFECTS = 'no effects'; @@ -40,6 +41,7 @@ describe('applicationStateMachine', () => { ${INSTALLED} | ${UPDATE_ERRORED} | ${{ updateFailed: true }} ${UNINSTALLING} | ${UNINSTALLING} | ${NO_EFFECTS} ${INSTALLED} | ${UNINSTALL_ERRORED} | ${{ uninstallFailed: true }} + ${UNINSTALLED} | ${UNINSTALLED} | ${NO_EFFECTS} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -74,8 +76,9 @@ describe('applicationStateMachine', () => { it.each` expectedState | event | effects ${INSTALLING} | ${INSTALL_EVENT} | ${{ installFailed: false }} - ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLED} | ${INSTALLED} | ${{ installFailed: false }} ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} + ${UNINSTALLED} | ${UNINSTALLED} | ${{ installFailed: false }} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -113,6 +116,8 @@ describe('applicationStateMachine', () => { ${UPDATING} | ${UPDATE_EVENT} | ${{ updateFailed: false, updateSuccessful: false }} ${UNINSTALLING} | ${UNINSTALL_EVENT} | ${{ uninstallFailed: false, uninstallSuccessful: false }} ${NOT_INSTALLABLE} | ${NOT_INSTALLABLE} | ${NO_EFFECTS} + ${UNINSTALLED} | ${UNINSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} `(`transitions to $expectedState on $event event and applies $effects`, data => { const { expectedState, event, effects } = data; const currentAppState = { @@ -162,6 +167,23 @@ describe('applicationStateMachine', () => { }); }); + describe(`current state is ${UNINSTALLED}`, () => { + it.each` + expectedState | event | effects + ${INSTALLED} | ${INSTALLED} | ${NO_EFFECTS} + ${INSTALLABLE} | ${ERROR} | ${{ installFailed: true }} + `(`transitions to $expectedState on $event event and applies $effects`, data => { + const { expectedState, event, effects } = data; + const currentAppState = { + status: UNINSTALLED, + }; + + expect(transitionApplicationState(currentAppState, event)).toEqual({ + status: expectedState, + ...noEffectsToEmptyObject(effects), + }); + }); + }); describe('current state is undefined', () => { it('returns the current state without having any effects', () => { const currentAppState = {}; diff --git a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js index 3e5f8de8e7b..57c538d2650 100644 --- a/spec/frontend/clusters/services/crossplane_provider_stack_spec.js +++ b/spec/frontend/clusters/services/crossplane_provider_stack_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem, GlIcon } from '@gitlab/ui'; import CrossplaneProviderStack from '~/clusters/components/crossplane_provider_stack.vue'; describe('CrossplaneProviderStack component', () => { @@ -37,7 +37,7 @@ describe('CrossplaneProviderStack component', () => { createComponent({ crossplane }); }); - const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem); const findFirstDropdownElement = () => findDropdownElements().at(0); afterEach(() => { diff --git a/spec/frontend/clusters/services/mock_data.js b/spec/frontend/clusters/services/mock_data.js index c5ec3f6e6a8..4f8b27d623c 100644 --- a/spec/frontend/clusters/services/mock_data.js +++ b/spec/frontend/clusters/services/mock_data.js @@ -151,7 +151,11 @@ const DEFAULT_APPLICATION_STATE = { const APPLICATIONS_MOCK_STATE = { helm: { title: 'Helm Tiller', status: 'installable' }, - ingress: { title: 'Ingress', status: 'installable', modsecurity_enabled: false }, + ingress: { + title: 'Ingress', + status: 'installable', + modsecurity_enabled: false, + }, crossplane: { title: 'Crossplane', status: 'installable', stack: '' }, cert_manager: { title: 'Cert-Manager', status: 'installable' }, runner: { title: 'GitLab Runner' }, @@ -160,6 +164,10 @@ const APPLICATIONS_MOCK_STATE = { knative: { title: 'Knative ', status: 'installable', hostname: '' }, elastic_stack: { title: 'Elastic Stack', status: 'installable' }, fluentd: { title: 'Fluentd', status: 'installable' }, + cilium: { + title: 'GitLab Container Network Policies', + status: 'not_installable', + }, }; export { CLUSTERS_MOCK_DATA, DEFAULT_APPLICATION_STATE, APPLICATIONS_MOCK_STATE }; diff --git a/spec/frontend/clusters/stores/clusters_store_spec.js b/spec/frontend/clusters/stores/clusters_store_spec.js index 36e99c37be5..ed862818c7b 100644 --- a/spec/frontend/clusters/stores/clusters_store_spec.js +++ b/spec/frontend/clusters/stores/clusters_store_spec.js @@ -66,6 +66,7 @@ describe('Clusters Store', () => { status: mockResponseData.applications[0].status, statusReason: mockResponseData.applications[0].status_reason, requestReason: null, + installable: true, installed: false, installFailed: false, uninstallable: false, @@ -80,6 +81,7 @@ describe('Clusters Store', () => { requestReason: null, externalIp: null, externalHostname: null, + installable: true, installed: false, isEditingModSecurityEnabled: false, isEditingModSecurityMode: false, @@ -100,6 +102,7 @@ describe('Clusters Store', () => { version: mockResponseData.applications[2].version, updateAvailable: mockResponseData.applications[2].update_available, chartRepo: 'https://gitlab.com/gitlab-org/charts/gitlab-runner', + installable: true, installed: false, installFailed: false, updateFailed: false, @@ -114,6 +117,7 @@ describe('Clusters Store', () => { status: APPLICATION_STATUS.INSTALLABLE, statusReason: mockResponseData.applications[3].status_reason, requestReason: null, + installable: true, installed: false, installFailed: true, uninstallable: false, @@ -130,6 +134,7 @@ describe('Clusters Store', () => { ciliumLogEnabled: null, host: null, protocol: null, + installable: true, installed: false, isEditingSettings: false, installFailed: false, @@ -145,6 +150,7 @@ describe('Clusters Store', () => { statusReason: mockResponseData.applications[4].status_reason, requestReason: null, hostname: '', + installable: true, installed: false, installFailed: false, uninstallable: false, @@ -161,6 +167,7 @@ describe('Clusters Store', () => { isEditingDomain: false, externalIp: null, externalHostname: null, + installable: true, installed: false, installFailed: false, uninstallable: false, @@ -177,6 +184,7 @@ describe('Clusters Store', () => { statusReason: mockResponseData.applications[6].status_reason, requestReason: null, email: mockResponseData.applications[6].email, + installable: true, installed: false, uninstallable: false, uninstallSuccessful: false, @@ -189,6 +197,7 @@ describe('Clusters Store', () => { installFailed: true, statusReason: mockResponseData.applications[7].status_reason, requestReason: null, + installable: true, installed: false, uninstallable: false, uninstallSuccessful: false, @@ -201,12 +210,26 @@ describe('Clusters Store', () => { installFailed: true, statusReason: mockResponseData.applications[8].status_reason, requestReason: null, + installable: true, installed: false, uninstallable: false, uninstallSuccessful: false, uninstallFailed: false, validationError: null, }, + cilium: { + title: 'GitLab Container Network Policies', + status: null, + statusReason: null, + requestReason: null, + installable: false, + installed: false, + installFailed: false, + uninstallable: false, + uninstallSuccessful: false, + uninstallFailed: false, + validationError: null, + }, }, environments: [], fetchingEnvironments: false, diff --git a/spec/frontend/clusters_list/components/ancestor_notice_spec.js b/spec/frontend/clusters_list/components/ancestor_notice_spec.js index c931912eaf9..cff84180f26 100644 --- a/spec/frontend/clusters_list/components/ancestor_notice_spec.js +++ b/spec/frontend/clusters_list/components/ancestor_notice_spec.js @@ -1,7 +1,7 @@ -import AncestorNotice from '~/clusters_list/components/ancestor_notice.vue'; -import ClusterStore from '~/clusters_list/store'; import { shallowMount } from '@vue/test-utils'; import { GlLink, GlSprintf } from '@gitlab/ui'; +import AncestorNotice from '~/clusters_list/components/ancestor_notice.vue'; +import ClusterStore from '~/clusters_list/store'; describe('ClustersAncestorNotice', () => { let store; diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index deb275a9bb9..c6a5f66a627 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -1,11 +1,11 @@ -import axios from '~/lib/utils/axios_utils'; -import Clusters from '~/clusters_list/components/clusters.vue'; -import ClusterStore from '~/clusters_list/store'; import MockAdapter from 'axios-mock-adapter'; -import { apiData } from '../mock_data'; import { mount } from '@vue/test-utils'; import { GlLoadingIcon, GlPagination, 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'; +import ClusterStore from '~/clusters_list/store'; +import { apiData } from '../mock_data'; describe('Clusters', () => { let mock; diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index c8556350747..053128a179a 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -1,14 +1,14 @@ import MockAdapter from 'axios-mock-adapter'; -import Poll from '~/lib/utils/poll'; -import flashError from '~/flash'; import testAction from 'helpers/vuex_action_helper'; -import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; +import * as Sentry from '@sentry/browser'; +import Poll from '~/lib/utils/poll'; +import { deprecatedCreateFlash as flashError } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; import { apiData } from '../mock_data'; import { MAX_REQUESTS } from '~/clusters_list/constants'; import * as types from '~/clusters_list/store/mutation_types'; import * as actions from '~/clusters_list/store/actions'; -import * as Sentry from '@sentry/browser'; jest.mock('~/flash.js'); diff --git a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap index 161c2bade05..745a163951a 100644 --- a/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap +++ b/spec/frontend/code_navigation/components/__snapshots__/popover_spec.js.snap @@ -56,7 +56,7 @@ exports[`Code navigation popover component renders popover 1`] = ` class="popover-body border-top" > <gl-button-stub - category="tertiary" + category="primary" class="w-100" data-testid="go-to-definition-btn" href="http://gitlab.com/test.js" diff --git a/spec/frontend/collapsed_sidebar_todo_spec.js b/spec/frontend/collapsed_sidebar_todo_spec.js index 0ea797ce4b3..0c74491aa74 100644 --- a/spec/frontend/collapsed_sidebar_todo_spec.js +++ b/spec/frontend/collapsed_sidebar_todo_spec.js @@ -1,10 +1,10 @@ /* eslint-disable no-new */ import { clone } from 'lodash'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; import Sidebar from '~/right_sidebar'; import waitForPromises from './helpers/wait_for_promises'; -import { TEST_HOST } from 'spec/test_constants'; describe('Issuable right sidebar collapsed todo toggle', () => { const fixtureName = 'issues/open-issue.html'; diff --git a/spec/frontend/commit/commit_pipeline_status_component_spec.js b/spec/frontend/commit/commit_pipeline_status_component_spec.js index 9281d1d02a3..1086985eec0 100644 --- a/spec/frontend/commit/commit_pipeline_status_component_spec.js +++ b/spec/frontend/commit/commit_pipeline_status_component_spec.js @@ -2,7 +2,7 @@ import Visibility from 'visibilityjs'; import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Poll from '~/lib/utils/poll'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; import CommitPipelineStatus from '~/projects/tree/components/commit_pipeline_status_component.vue'; import { getJSONFixture } from '../helpers/fixtures'; diff --git a/spec/frontend/commit/pipelines/pipelines_spec.js b/spec/frontend/commit/pipelines/pipelines_spec.js index 86ae207e7b7..fdf3c2e85f3 100644 --- a/spec/frontend/commit/pipelines/pipelines_spec.js +++ b/spec/frontend/commit/pipelines/pipelines_spec.js @@ -121,14 +121,14 @@ describe('Pipelines table in Commits and Merge requests', () => { pipelineCopy = { ...pipeline }; }); - describe('when latest pipeline has detached flag and canRunPipeline is true', () => { + describe('when latest pipeline has detached flag', () => { it('renders the run pipeline button', done => { pipelineCopy.flags.detached_merge_request_pipeline = true; pipelineCopy.flags.merge_request_pipeline = true; mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true }); + vm = mountComponent(PipelinesTable, { ...props }); setImmediate(() => { expect(vm.$el.querySelector('.js-run-mr-pipeline')).not.toBeNull(); @@ -137,14 +137,14 @@ describe('Pipelines table in Commits and Merge requests', () => { }); }); - describe('when latest pipeline has detached flag and canRunPipeline is false', () => { + describe('when latest pipeline does not have detached flag', () => { it('does not render the run pipeline button', done => { - pipelineCopy.flags.detached_merge_request_pipeline = true; - pipelineCopy.flags.merge_request_pipeline = true; + pipelineCopy.flags.detached_merge_request_pipeline = false; + pipelineCopy.flags.merge_request_pipeline = false; mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false }); + vm = mountComponent(PipelinesTable, { ...props }); setImmediate(() => { expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); @@ -153,39 +153,47 @@ describe('Pipelines table in Commits and Merge requests', () => { }); }); - describe('when latest pipeline does not have detached flag and canRunPipeline is true', () => { - it('does not render the run pipeline button', done => { - pipelineCopy.flags.detached_merge_request_pipeline = false; - pipelineCopy.flags.merge_request_pipeline = false; + describe('on click', () => { + const findModal = () => + document.querySelector('#create-pipeline-for-fork-merge-request-modal'); - mock.onGet('endpoint.json').reply(200, [pipelineCopy]); + beforeEach(() => { + pipelineCopy.flags.detached_merge_request_pipeline = true; - vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: true }); + mock.onGet('endpoint.json').reply(200, [pipelineCopy]); - setImmediate(() => { - expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); - done(); + vm = mountComponent(PipelinesTable, { + ...props, + canRunPipeline: true, + projectId: '5', + mergeRequestId: 3, }); }); - }); - describe('when latest pipeline does not have detached flag and merge_request_pipeline is true', () => { - it('does not render the run pipeline button', done => { - pipelineCopy.flags.detached_merge_request_pipeline = false; - pipelineCopy.flags.merge_request_pipeline = true; + it('updates the loading state', done => { + jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); - mock.onGet('endpoint.json').reply(200, [pipelineCopy]); + setImmediate(() => { + vm.$el.querySelector('.js-run-mr-pipeline').click(); - vm = mountComponent(PipelinesTable, { ...props, canRunPipeline: false }); + vm.$nextTick(() => { + expect(findModal()).toBeNull(); + expect(vm.state.isRunningMergeRequestPipeline).toBe(true); - setImmediate(() => { - expect(vm.$el.querySelector('.js-run-mr-pipeline')).toBeNull(); - done(); + setImmediate(() => { + expect(vm.state.isRunningMergeRequestPipeline).toBe(false); + + done(); + }); + }); }); }); }); - describe('on click', () => { + describe('on click for fork merge request', () => { + const findModal = () => + document.querySelector('#create-pipeline-for-fork-merge-request-modal'); + beforeEach(() => { pipelineCopy.flags.detached_merge_request_pipeline = true; @@ -193,26 +201,23 @@ describe('Pipelines table in Commits and Merge requests', () => { vm = mountComponent(PipelinesTable, { ...props, - canRunPipeline: true, projectId: '5', mergeRequestId: 3, + canCreatePipelineInTargetProject: true, + sourceProjectFullPath: 'test/parent-project', + targetProjectFullPath: 'test/fork-project', }); }); - it('updates the loading state', done => { + it('shows a security warning modal', done => { jest.spyOn(Api, 'postMergeRequestPipeline').mockReturnValue(Promise.resolve()); setImmediate(() => { vm.$el.querySelector('.js-run-mr-pipeline').click(); vm.$nextTick(() => { - expect(vm.state.isRunningMergeRequestPipeline).toBe(true); - - setImmediate(() => { - expect(vm.state.isRunningMergeRequestPipeline).toBe(false); - - done(); - }); + expect(findModal()).not.toBeNull(); + done(); }); }); }); diff --git a/spec/frontend/confidential_merge_request/components/dropdown_spec.js b/spec/frontend/confidential_merge_request/components/dropdown_spec.js index 69495f3c161..3e95cd6c0d7 100644 --- a/spec/frontend/confidential_merge_request/components/dropdown_spec.js +++ b/spec/frontend/confidential_merge_request/components/dropdown_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem } from '@gitlab/ui'; import Dropdown from '~/confidential_merge_request/components/dropdown.vue'; let vm; @@ -30,7 +30,7 @@ describe('Confidential merge request project dropdown component', () => { }, ]); - expect(vm.findAll(GlDropdownItem).length).toBe(2); + expect(vm.findAll(GlDeprecatedDropdownItem).length).toBe(2); }); it('renders selected project icon', () => { diff --git a/spec/frontend/confirm_modal_spec.js b/spec/frontend/confirm_modal_spec.js index b14d1c3e01d..70076532a94 100644 --- a/spec/frontend/confirm_modal_spec.js +++ b/spec/frontend/confirm_modal_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import initConfirmModal from '~/confirm_modal'; import { TEST_HOST } from 'helpers/test_constants'; +import initConfirmModal from '~/confirm_modal'; describe('ConfirmModal', () => { const buttons = [ diff --git a/spec/frontend/contributors/store/actions_spec.js b/spec/frontend/contributors/store/actions_spec.js index 55437da837c..ad490ea4b67 100644 --- a/spec/frontend/contributors/store/actions_spec.js +++ b/spec/frontend/contributors/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; -import flashError from '~/flash'; +import { deprecatedCreateFlash as flashError } from '~/flash'; import * as actions from '~/contributors/stores/actions'; import * as types from '~/contributors/stores/mutation_types'; 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 01f7ada9cd6..882a4a002bd 100644 --- a/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js +++ b/spec/frontend/create_cluster/eks_cluster/store/actions_spec.js @@ -23,7 +23,7 @@ import { CREATE_CLUSTER_ERROR, } from '~/create_cluster/eks_cluster/store/mutation_types'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js new file mode 100644 index 00000000000..9ecf6bf375b --- /dev/null +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_modal_spec.js @@ -0,0 +1,92 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { GlButton, GlModal } from '@gitlab/ui'; +import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; +import createStore from '~/deploy_freeze/store'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Deploy freeze modal', () => { + let wrapper; + let store; + const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); + const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + + beforeEach(() => { + store = createStore({ + projectId: '8', + timezoneData: timezoneDataFixture, + }); + wrapper = shallowMount(DeployFreezeModal, { + attachToDocument: true, + stubs: { + GlModal, + }, + localVue, + store, + }); + }); + + const findModal = () => wrapper.find(GlModal); + const addDeployFreezeButton = () => + findModal() + .findAll(GlButton) + .at(1); + + const setInput = (freezeStartCron, freezeEndCron, selectedTimezone) => { + store.state.freezeStartCron = freezeStartCron; + store.state.freezeEndCron = freezeEndCron; + store.state.selectedTimezone = selectedTimezone; + + wrapper.find('#deploy-freeze-start').trigger('input'); + wrapper.find('#deploy-freeze-end').trigger('input'); + wrapper.find(TimezoneDropdown).trigger('input'); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('Basic interactions', () => { + it('button is disabled when freeze period is invalid', () => { + expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy(); + }); + }); + + describe('Adding a new deploy freeze', () => { + beforeEach(() => { + const { freeze_start, freeze_end, cron_timezone } = freezePeriodsFixture[0]; + setInput(freeze_start, freeze_end, cron_timezone); + }); + + it('button is enabled when valid freeze period settings are present', () => { + expect(addDeployFreezeButton().attributes('disabled')).toBeUndefined(); + }); + }); + + describe('Validations', () => { + describe('when the cron state is invalid', () => { + beforeEach(() => { + setInput('invalid cron', 'invalid cron', 'invalid timezone'); + }); + + it('disables the add deploy freeze button', () => { + expect(addDeployFreezeButton().attributes('disabled')).toBeTruthy(); + }); + }); + + describe('when the cron state is valid', () => { + beforeEach(() => { + const { freeze_start, freeze_end, cron_timezone } = freezePeriodsFixture[0]; + setInput(freeze_start, freeze_end, cron_timezone); + }); + + it('does not disable the submit button', () => { + expect(addDeployFreezeButton().attributes('disabled')).toBeFalsy(); + }); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js new file mode 100644 index 00000000000..d40df7de7d1 --- /dev/null +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_settings_spec.js @@ -0,0 +1,42 @@ +import Vuex from 'vuex'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import DeployFreezeSettings from '~/deploy_freeze/components/deploy_freeze_settings.vue'; +import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; +import DeployFreezeModal from '~/deploy_freeze/components/deploy_freeze_modal.vue'; +import createStore from '~/deploy_freeze/store'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Deploy freeze settings', () => { + let wrapper; + let store; + const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + + beforeEach(() => { + store = createStore({ + projectId: '8', + timezoneData: timezoneDataFixture, + }); + jest.spyOn(store, 'dispatch').mockImplementation(); + wrapper = shallowMount(DeployFreezeSettings, { + localVue, + store, + }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('Deploy freeze table contains components', () => { + it('contains deploy freeze table', () => { + expect(wrapper.find(DeployFreezeTable).exists()).toBe(true); + }); + + it('contains deploy freeze modal', () => { + expect(wrapper.find(DeployFreezeModal).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js new file mode 100644 index 00000000000..383ffa90b22 --- /dev/null +++ b/spec/frontend/deploy_freeze/components/deploy_freeze_table_spec.js @@ -0,0 +1,70 @@ +import Vuex from 'vuex'; +import { createLocalVue, mount } from '@vue/test-utils'; +import DeployFreezeTable from '~/deploy_freeze/components/deploy_freeze_table.vue'; +import createStore from '~/deploy_freeze/store'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Deploy freeze table', () => { + let wrapper; + let store; + const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + + const createComponent = () => { + store = createStore({ + projectId: '8', + timezoneData: timezoneDataFixture, + }); + jest.spyOn(store, 'dispatch').mockImplementation(); + wrapper = mount(DeployFreezeTable, { + attachToDocument: true, + localVue, + store, + }); + }; + + const findEmptyFreezePeriods = () => wrapper.find('[data-testid="empty-freeze-periods"]'); + const findAddDeployFreezeButton = () => wrapper.find('[data-testid="add-deploy-freeze"]'); + const findDeployFreezeTable = () => wrapper.find('[data-testid="deploy-freeze-table"]'); + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('dispatches fetchFreezePeriods when mounted', () => { + expect(store.dispatch).toHaveBeenCalledWith('fetchFreezePeriods'); + }); + + describe('Renders correct data', () => { + it('displays empty', () => { + expect(findEmptyFreezePeriods().exists()).toBe(true); + expect(findEmptyFreezePeriods().text()).toBe( + 'No deploy freezes exist for this project. To add one, click Add deploy freeze', + ); + }); + + it('displays data', () => { + const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); + store.state.freezePeriods = freezePeriodsFixture; + + return wrapper.vm.$nextTick(() => { + const tableRows = findDeployFreezeTable().findAll('tbody tr'); + expect(tableRows.length).toBe(freezePeriodsFixture.length); + expect(findEmptyFreezePeriods().exists()).toBe(false); + }); + }); + }); + + describe('Table click actions', () => { + it('displays add deploy freeze button', () => { + expect(findAddDeployFreezeButton().exists()).toBe(true); + expect(findAddDeployFreezeButton().text()).toBe('Add deploy freeze'); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js new file mode 100644 index 00000000000..644cd0b5f27 --- /dev/null +++ b/spec/frontend/deploy_freeze/components/timezone_dropdown_spec.js @@ -0,0 +1,98 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlDeprecatedDropdownItem, GlNewDropdown } from '@gitlab/ui'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown.vue'; +import createStore from '~/deploy_freeze/store'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('Deploy freeze timezone dropdown', () => { + let wrapper; + let store; + const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + + const createComponent = (searchTerm, selectedTimezone) => { + store = createStore({ + projectId: '8', + timezoneData: timezoneDataFixture, + }); + wrapper = shallowMount(TimezoneDropdown, { + store, + localVue, + propsData: { + value: selectedTimezone, + timezoneData: timezoneDataFixture, + }, + }); + + wrapper.setData({ searchTerm }); + }; + + const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); + const findDropdownItemByIndex = index => wrapper.findAll(GlDeprecatedDropdownItem).at(index); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('No time zones found', () => { + beforeEach(() => { + createComponent('UTC timezone'); + }); + + it('renders empty results message', () => { + expect(findDropdownItemByIndex(0).text()).toBe('No matching results'); + }); + }); + + describe('Search term is empty', () => { + beforeEach(() => { + createComponent(''); + }); + + it('renders all timezones when search term is empty', () => { + expect(findAllDropdownItems()).toHaveLength(timezoneDataFixture.length); + }); + }); + + describe('Time zones found', () => { + beforeEach(() => { + createComponent('Alaska'); + }); + + it('renders only the time zone searched for', () => { + expect(findAllDropdownItems()).toHaveLength(1); + expect(findDropdownItemByIndex(0).text()).toBe('[UTC -8] Alaska'); + }); + + it('should not display empty results message', () => { + expect(wrapper.find('[data-testid="noMatchingResults"]').exists()).toBe(false); + }); + + describe('Custom events', () => { + it('should emit input if a time zone is clicked', () => { + findDropdownItemByIndex(0).vm.$emit('click'); + expect(wrapper.emitted('input')).toEqual([ + [ + { + formattedTimezone: '[UTC -8] Alaska', + identifier: 'America/Juneau', + }, + ], + ]); + }); + }); + }); + + describe('Selected time zone', () => { + beforeEach(() => { + createComponent('', 'Alaska'); + }); + + it('renders selected time zone as dropdown label', () => { + expect(wrapper.find(GlNewDropdown).vm.text).toBe('Alaska'); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js new file mode 100644 index 00000000000..97f94cdbf5e --- /dev/null +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -0,0 +1,123 @@ +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; +import getInitialState from '~/deploy_freeze/store/state'; +import * as actions from '~/deploy_freeze/store/actions'; +import * as types from '~/deploy_freeze/store/mutation_types'; + +jest.mock('~/api.js'); +jest.mock('~/flash.js'); + +describe('deploy freeze store actions', () => { + let mock; + let state; + const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); + const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + + beforeEach(() => { + mock = new MockAdapter(axios); + state = getInitialState({ + projectId: '8', + timezoneData: timezoneDataFixture, + }); + Api.freezePeriods.mockResolvedValue({ data: freezePeriodsFixture }); + Api.createFreezePeriod.mockResolvedValue(); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('setSelectedTimezone', () => { + it('commits SET_SELECTED_TIMEZONE mutation', () => { + testAction(actions.setSelectedTimezone, {}, {}, [ + { + payload: {}, + type: types.SET_SELECTED_TIMEZONE, + }, + ]); + }); + }); + + describe('setFreezeStartCron', () => { + it('commits SET_FREEZE_START_CRON mutation', () => { + testAction(actions.setFreezeStartCron, {}, {}, [ + { + type: types.SET_FREEZE_START_CRON, + }, + ]); + }); + }); + + describe('setFreezeEndCron', () => { + it('commits SET_FREEZE_END_CRON mutation', () => { + testAction(actions.setFreezeEndCron, {}, {}, [ + { + type: types.SET_FREEZE_END_CRON, + }, + ]); + }); + }); + + describe('addFreezePeriod', () => { + it('dispatch correct actions on adding a freeze period', () => { + testAction( + actions.addFreezePeriod, + {}, + state, + [{ type: 'RESET_MODAL' }], + [ + { type: 'requestAddFreezePeriod' }, + { type: 'receiveAddFreezePeriodSuccess' }, + { type: 'fetchFreezePeriods' }, + ], + ); + }); + + it('should show flash error and set error in state on add failure', () => { + Api.createFreezePeriod.mockRejectedValue(); + + testAction( + actions.addFreezePeriod, + {}, + state, + [], + [{ type: 'requestAddFreezePeriod' }, { type: 'receiveAddFreezePeriodError' }], + () => expect(createFlash).toHaveBeenCalled(), + ); + }); + }); + + describe('fetchFreezePeriods', () => { + it('dispatch correct actions on fetchFreezePeriods', () => { + testAction( + actions.fetchFreezePeriods, + {}, + state, + [ + { type: types.REQUEST_FREEZE_PERIODS }, + { type: types.RECEIVE_FREEZE_PERIODS_SUCCESS, payload: freezePeriodsFixture }, + ], + [], + ); + }); + + it('should show flash error and set error in state on fetch variables failure', () => { + Api.freezePeriods.mockRejectedValue(); + + testAction( + actions.fetchFreezePeriods, + {}, + state, + [{ type: types.REQUEST_FREEZE_PERIODS }], + [], + () => + expect(createFlash).toHaveBeenCalledWith( + 'There was an error fetching the deploy freezes.', + ), + ); + }); + }); +}); diff --git a/spec/frontend/deploy_freeze/store/mutations_spec.js b/spec/frontend/deploy_freeze/store/mutations_spec.js new file mode 100644 index 00000000000..0453e037e15 --- /dev/null +++ b/spec/frontend/deploy_freeze/store/mutations_spec.js @@ -0,0 +1,72 @@ +import state from '~/deploy_freeze/store/state'; +import mutations from '~/deploy_freeze/store/mutations'; +import * as types from '~/deploy_freeze/store/mutation_types'; +import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; + +describe('Deploy freeze mutations', () => { + let stateCopy; + const timezoneDataFixture = getJSONFixture('/api/freeze-periods/timezone_data.json'); + + beforeEach(() => { + stateCopy = state({ + projectId: '8', + timezoneData: timezoneDataFixture, + }); + }); + + describe('RESET_MODAL', () => { + it('should reset modal state', () => { + mutations[types.RESET_MODAL](stateCopy); + + expect(stateCopy.freezeStartCron).toBe(''); + expect(stateCopy.freezeEndCron).toBe(''); + expect(stateCopy.selectedTimezone).toBe(''); + expect(stateCopy.selectedTimezoneIdentifier).toBe(''); + }); + }); + + describe('RECEIVE_FREEZE_PERIODS_SUCCESS', () => { + it('should set freeze periods and format timezones from identifiers to names', () => { + const timezoneNames = ['Berlin', 'UTC', 'Eastern Time (US & Canada)']; + const freezePeriodsFixture = getJSONFixture('/api/freeze-periods/freeze_periods.json'); + + mutations[types.RECEIVE_FREEZE_PERIODS_SUCCESS](stateCopy, freezePeriodsFixture); + + const expectedFreezePeriods = freezePeriodsFixture.map((freezePeriod, index) => ({ + ...convertObjectPropsToCamelCase(freezePeriod), + cronTimezone: timezoneNames[index], + })); + + expect(stateCopy.freezePeriods).toMatchObject(expectedFreezePeriods); + }); + }); + + describe('SET_SELECTED_TIMEZONE', () => { + it('should set the cron timezone', () => { + const timezone = { + formattedTimezone: '[UTC -7] Pacific Time (US & Canada)', + identifier: 'America/Los_Angeles', + }; + mutations[types.SET_SELECTED_TIMEZONE](stateCopy, timezone); + + expect(stateCopy.selectedTimezone).toEqual(timezone.formattedTimezone); + expect(stateCopy.selectedTimezoneIdentifier).toEqual(timezone.identifier); + }); + }); + + describe('SET_FREEZE_START_CRON', () => { + it('should set freezeStartCron', () => { + mutations[types.SET_FREEZE_START_CRON](stateCopy, '5 0 * 8 *'); + + expect(stateCopy.freezeStartCron).toBe('5 0 * 8 *'); + }); + }); + + describe('SET_FREEZE_ENDT_CRON', () => { + it('should set freezeEndCron', () => { + mutations[types.SET_FREEZE_END_CRON](stateCopy, '5 0 * 8 *'); + + expect(stateCopy.freezeEndCron).toBe('5 0 * 8 *'); + }); + }); +}); diff --git a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap index 4c848256e5b..62a0f675cff 100644 --- a/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap +++ b/spec/frontend/design_management/components/__snapshots__/design_note_pin_spec.js.snap @@ -3,13 +3,13 @@ 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 btn-transparent comment-indicator" + 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" > - <icon-stub + <gl-icon-stub name="image-comment-dark" - size="16" + size="24" /> </button> `; @@ -17,7 +17,7 @@ exports[`Design note pin component should match the snapshot of note when reposi 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 js-image-badge badge badge-pill" + 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" > @@ -30,13 +30,13 @@ exports[`Design note pin component should match the snapshot of note with index 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 btn-transparent comment-indicator" + 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" > - <icon-stub + <gl-icon-stub name="image-comment-dark" - size="16" + size="24" /> </button> `; diff --git a/spec/frontend/design_management/components/delete_button_spec.js b/spec/frontend/design_management/components/delete_button_spec.js index 9d3bcd98e44..cd4ef1f0ccd 100644 --- a/spec/frontend/design_management/components/delete_button_spec.js +++ b/spec/frontend/design_management/components/delete_button_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlModal, GlModalDirective } from '@gitlab/ui'; +import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; import BatchDeleteButton from '~/design_management/components/delete_button.vue'; describe('Batch delete button component', () => { let wrapper; - const findButton = () => wrapper.find(GlDeprecatedButton); + const findButton = () => wrapper.find(GlButton); const findModal = () => wrapper.find(GlModal); function createComponent(isDeleting = false) { 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 102e8e0664c..176c10ea584 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 @@ -61,6 +61,10 @@ describe('Design discussions component', () => { ...data, }; }, + provide: { + projectPath: 'project-path', + issueIid: '1', + }, mocks: { $apollo, $route: { 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 9cd427f6aae..d76b6e712fe 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 @@ -8,328 +8,9 @@ exports[`Design management list item component when item appears in view after i /> `; -exports[`Design management list item component with no notes renders item with correct status icon for creation event 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" - > - <div - class="design-event position-absolute" - > - <span - aria-label="Added in this version" - title="Added in this version" - > - <icon-stub - class="text-success-500" - name="file-addition-solid" - size="18" - /> - </span> - </div> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders item with correct status icon for deletion event 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" - > - <div - class="design-event position-absolute" - > - <span - aria-label="Deleted in this version" - title="Deleted in this version" - > - <icon-stub - class="text-danger-500" - name="file-deletion-solid" - size="18" - /> - </span> - </div> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders item with correct status icon for modification event 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" - > - <div - class="design-event position-absolute" - > - <span - aria-label="Modified in this version" - title="Modified in this version" - > - <icon-stub - class="text-primary-500" - name="file-modified-solid" - size="18" - /> - </span> - </div> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders item with no status icon for none event 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 - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders loading spinner when isUploading is true 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 - options="[object Object]" - > - <gl-loading-icon-stub - color="orange" - label="Loading" - size="md" - /> - - <img - alt="test" - class="block mx-auto mw-100 mh-100 design-img" - data-qa-selector="design_image" - src="" - style="display: none;" - /> - </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> -</router-link-stub> -`; - 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" + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" to="[object Object]" > <div @@ -337,9 +18,7 @@ exports[`Design management list item component with notes renders item with mult > <!----> - <gl-intersection-observer-stub - options="[object Object]" - > + <gl-intersection-observer-stub> <!----> <img @@ -401,7 +80,7 @@ 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" + class="card cursor-pointer text-plain js-design-list-item design-list-item design-list-item-new" to="[object Object]" > <div @@ -409,9 +88,7 @@ exports[`Design management list item component with notes renders item with sing > <!----> - <gl-intersection-observer-stub - options="[object Object]" - > + <gl-intersection-observer-stub> <!----> <img diff --git a/spec/frontend/design_management/components/list/item_spec.js b/spec/frontend/design_management/components/list/item_spec.js index 705b532454f..d1c90bd57b0 100644 --- a/spec/frontend/design_management/components/list/item_spec.js +++ b/spec/frontend/design_management/components/list/item_spec.js @@ -1,6 +1,7 @@ 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(); @@ -18,6 +19,10 @@ const DESIGN_VERSION_EVENT = { 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, @@ -134,35 +139,31 @@ describe('Design management list item component', () => { }); }); - describe('with no notes', () => { - it('renders item with no status icon for none event', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders item with correct status icon for modification event', () => { - createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders item with correct status icon for deletion event', () => { - createComponent({ event: DESIGN_VERSION_EVENT.DELETION }); + it('renders loading spinner when isUploading is true', () => { + createComponent({ isUploading: true }); - expect(wrapper.element).toMatchSnapshot(); - }); + expect(findLoadingIcon().exists()).toBe(true); + }); - it('renders item with correct status icon for creation event', () => { - createComponent({ event: DESIGN_VERSION_EVENT.CREATION }); + it('renders item with no status icon for none event', () => { + createComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders loading spinner when isUploading is true', () => { - createComponent({ isUploading: true }); + expect(findDesignEvent().exists()).toBe(false); + }); - expect(wrapper.element).toMatchSnapshot(); + 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_new/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap index 0197b4bff79..a7d6145285c 100644 --- a/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/design_navigation_spec.js.snap @@ -2,28 +2,34 @@ exports[`Design management pagination component hides components when designs are empty 1`] = `<!---->`; -exports[`Design management pagination component renders pagination buttons 1`] = ` +exports[`Design management pagination component renders navigation buttons 1`] = ` <div class="d-flex align-items-center" > 0 of 2 - <div - class="btn-group ml-3 mr-3" + <gl-button-group-stub + class="ml-3 mr-3" > - <pagination-button-stub + <gl-button-stub + category="primary" class="js-previous-design" - iconname="angle-left" + disabled="true" + icon="angle-left" + size="medium" title="Go to previous design" + variant="default" /> - <pagination-button-stub + <gl-button-stub + category="primary" class="js-next-design" - design="[object Object]" - iconname="angle-right" + icon="angle-right" + size="medium" title="Go to next design" + variant="default" /> - </div> + </gl-button-group-stub> </div> `; diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap index e55cff8de3d..b286a74ebb8 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/components/toolbar/__snapshots__/index_spec.js.snap @@ -2,60 +2,60 @@ 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" + class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-bg-white gl-py-4 gl-pl-4 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" + class="gl-display-flex gl-align-items-center" > - <h2 - class="m-0 str-truncated-100 gl-font-base" + <a + aria-label="Go back to designs" + class="gl-mr-5 gl-display-flex gl-align-items-center gl-justify-content-center text-plain" + data-testid="close-design" > - test.jpg - </h2> + <gl-icon-stub + name="close" + size="16" + /> + </a> - <small - class="text-secondary" + <div + class="overflow-hidden d-flex align-items-center" > - Updated 1 hour ago by Test Name - </small> + <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> </div> - <pagination-stub + <design-navigation-stub class="ml-auto flex-shrink-0" id="1" /> - <gl-deprecated-button-stub - class="mr-2" + <gl-button-stub + category="primary" href="/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d" - size="md" - variant="secondary" - > - <icon-stub - name="download" - size="18" - /> - </gl-deprecated-button-stub> + icon="download" + size="medium" + variant="default" + /> <delete-button-stub + buttoncategory="secondary" buttonclass="" - buttonvariant="danger" + buttonicon="archive" + buttonsize="medium" + buttonvariant="warning" + class="gl-ml-3" hasselecteddesigns="true" - > - <icon-stub - name="remove" - size="18" - /> - </delete-button-stub> + /> </header> `; diff --git a/spec/frontend/design_management_new/components/toolbar/pagination_spec.js b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js index 45dce15e292..1c6588a9628 100644 --- a/spec/frontend/design_management_new/components/toolbar/pagination_spec.js +++ b/spec/frontend/design_management/components/toolbar/design_navigation_spec.js @@ -1,8 +1,8 @@ /* global Mousetrap */ import 'mousetrap'; import { shallowMount } from '@vue/test-utils'; -import Pagination from '~/design_management_new/components/toolbar/pagination.vue'; -import { DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants'; +import DesignNavigation from '~/design_management/components/toolbar/design_navigation.vue'; +import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; const push = jest.fn(); const $router = { @@ -18,7 +18,7 @@ describe('Design management pagination component', () => { let wrapper; function createComponent() { - wrapper = shallowMount(Pagination, { + wrapper = shallowMount(DesignNavigation, { propsData: { id: '2', }, @@ -41,7 +41,7 @@ describe('Design management pagination component', () => { expect(wrapper.element).toMatchSnapshot(); }); - it('renders pagination buttons', () => { + it('renders navigation buttons', () => { wrapper.setData({ designs: [{ id: '1' }, { id: '2' }], }); diff --git a/spec/frontend/design_management/components/toolbar/index_spec.js b/spec/frontend/design_management/components/toolbar/index_spec.js index 2910b2f62ba..2914365b0df 100644 --- a/spec/frontend/design_management/components/toolbar/index_spec.js +++ b/spec/frontend/design_management/components/toolbar/index_spec.js @@ -1,9 +1,9 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueRouter from 'vue-router'; +import { GlButton } from '@gitlab/ui'; import Toolbar from '~/design_management/components/toolbar/index.vue'; import DeleteButton from '~/design_management/components/delete_button.vue'; import { DESIGNS_ROUTE_NAME } from '~/design_management/router/constants'; -import { GlDeprecatedButton } from '@gitlab/ui'; const localVue = createLocalVue(); localVue.use(VueRouter); @@ -116,7 +116,7 @@ describe('Design management toolbar component', () => { }); it('renders download button with correct link', () => { - expect(wrapper.find(GlDeprecatedButton).attributes('href')).toBe( + expect(wrapper.find(GlButton).attributes('href')).toBe( '/-/designs/306/7f747adcd4693afadbe968d7ba7d983349b9012d', ); }); diff --git a/spec/frontend/design_management/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management/components/toolbar/pagination_button_spec.js deleted file mode 100644 index b7df201795b..00000000000 --- a/spec/frontend/design_management/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/components/toolbar/pagination_button.vue'; -import { DESIGN_ROUTE_NAME } from '~/design_management/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/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap index 27c0ba589e6..3d7939df28e 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/button_spec.js.snap @@ -4,16 +4,18 @@ exports[`Design management upload button component renders inverted upload desig <div isinverted="true" > - <gl-deprecated-button-stub - size="md" + <gl-button-stub + category="primary" + icon="" + size="small" title="Adding a design with the same filename replaces the file in a new version." - variant="success" + variant="default" > Upload designs <!----> - </gl-deprecated-button-stub> + </gl-button-stub> <input accept="image/*" @@ -27,11 +29,13 @@ exports[`Design management upload button component renders inverted upload desig exports[`Design management upload button component renders loading icon 1`] = ` <div> - <gl-deprecated-button-stub + <gl-button-stub + category="primary" disabled="true" - size="md" + icon="" + size="small" title="Adding a design with the same filename replaces the file in a new version." - variant="success" + variant="default" > Upload designs @@ -43,7 +47,7 @@ exports[`Design management upload button component renders loading icon 1`] = ` label="Loading" size="sm" /> - </gl-deprecated-button-stub> + </gl-button-stub> <input accept="image/*" @@ -57,16 +61,18 @@ exports[`Design management upload button component renders loading icon 1`] = ` exports[`Design management upload button component renders upload design button 1`] = ` <div> - <gl-deprecated-button-stub - size="md" + <gl-button-stub + category="primary" + icon="" + size="small" title="Adding a design with the same filename replaces the file in a new version." - variant="success" + variant="default" > Upload designs <!----> - </gl-deprecated-button-stub> + </gl-button-stub> <input accept="image/*" diff --git a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap index 0737b9729a2..9284099b40d 100644 --- a/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap +++ b/spec/frontend/design_management/components/upload/__snapshots__/design_dropzone_spec.js.snap @@ -5,20 +5,23 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" > <div - class="d-flex-center flex-column text-center" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" > <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" + class="gl-mb-2" + name="upload" + size="24" /> - <p> + <p + class="gl-mb-0" + > <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + message="Drop or %{linkStart}upload%{linkEnd} designs to attach" /> </p> </div> @@ -43,7 +46,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -56,7 +61,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="" > - <h3> + <h3 + class="" + > Incoming! </h3> @@ -74,20 +81,23 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" > <div - class="d-flex-center flex-column text-center" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" > <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" + class="gl-mb-2" + name="upload" + size="24" /> - <p> + <p + class="gl-mb-0" + > <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + message="Drop or %{linkStart}upload%{linkEnd} designs to attach" /> </p> </div> @@ -112,7 +122,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -125,7 +137,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="" > - <h3> + <h3 + class="" + > Incoming! </h3> @@ -143,20 +157,23 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" > <div - class="d-flex-center flex-column text-center" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" > <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" + class="gl-mb-2" + name="upload" + size="24" /> - <p> + <p + class="gl-mb-0" + > <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + message="Drop or %{linkStart}upload%{linkEnd} designs to attach" /> </p> </div> @@ -180,7 +197,9 @@ exports[`Design management dropzone component when dragging renders correct temp <div class="mw-50 text-center" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -193,7 +212,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Incoming! </h3> @@ -211,20 +232,23 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" > <div - class="d-flex-center flex-column text-center" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" > <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" + class="gl-mb-2" + name="upload" + size="24" /> - <p> + <p + class="gl-mb-0" + > <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + message="Drop or %{linkStart}upload%{linkEnd} designs to attach" /> </p> </div> @@ -248,7 +272,9 @@ exports[`Design management dropzone component when dragging renders correct temp <div class="mw-50 text-center" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -261,7 +287,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Incoming! </h3> @@ -279,20 +307,23 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" > <div - class="d-flex-center flex-column text-center" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" > <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" + class="gl-mb-2" + name="upload" + size="24" /> - <p> + <p + class="gl-mb-0" + > <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + message="Drop or %{linkStart}upload%{linkEnd} designs to attach" /> </p> </div> @@ -316,7 +347,9 @@ exports[`Design management dropzone component when dragging renders correct temp <div class="mw-50 text-center" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -329,7 +362,9 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Incoming! </h3> @@ -347,20 +382,23 @@ exports[`Design management dropzone component when no slot provided renders defa class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" > <div - class="d-flex-center flex-column text-center" + class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" + data-testid="dropzone-area" > <gl-icon-stub - class="mb-4" - name="doc-new" - size="48" + class="gl-mb-2" + name="upload" + size="24" /> - <p> + <p + class="gl-mb-0" + > <gl-sprintf-stub - message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." + message="Drop or %{linkStart}upload%{linkEnd} designs to attach" /> </p> </div> @@ -384,7 +422,9 @@ exports[`Design management dropzone component when no slot provided renders defa <div class="mw-50 text-center" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -397,7 +437,9 @@ exports[`Design management dropzone component when no slot provided renders defa class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Incoming! </h3> @@ -428,7 +470,9 @@ exports[`Design management dropzone component when slot provided renders dropzon <div class="mw-50 text-center" > - <h3> + <h3 + class="" + > Oh no! </h3> @@ -441,7 +485,9 @@ exports[`Design management dropzone component when slot provided renders dropzon class="mw-50 text-center" style="display: none;" > - <h3> + <h3 + class="" + > Incoming! </h3> 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 00f1a40dfb2..d6fd09eb698 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,111 +1,77 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` -<gl-dropdown-stub - class="design-version-dropdown" +<gl-new-dropdown-stub + category="tertiary" + headertext="" issueiid="" projectpath="" - text="Showing Latest Version" - variant="link" + size="small" + text="Showing latest version" + variant="default" > - <gl-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 pull-right" - /> - </router-link-stub> - </gl-dropdown-item-stub> - <gl-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-dropdown-item-stub> -</gl-dropdown-stub> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischecked="true" + ischeckitem="true" + secondarytext="" + > + Version + 2 + (latest) + </gl-new-dropdown-item-stub> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischeckitem="true" + secondarytext="" + > + Version + 1 + + </gl-new-dropdown-item-stub> +</gl-new-dropdown-stub> `; exports[`Design management design version dropdown component renders design version list 1`] = ` -<gl-dropdown-stub - class="design-version-dropdown" +<gl-new-dropdown-stub + category="tertiary" + headertext="" issueiid="" projectpath="" - text="Showing Latest Version" - variant="link" + size="small" + text="Showing latest version" + variant="default" > - <gl-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 pull-right" - /> - </router-link-stub> - </gl-dropdown-item-stub> - <gl-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-dropdown-item-stub> -</gl-dropdown-stub> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischecked="true" + ischeckitem="true" + secondarytext="" + > + Version + 2 + (latest) + </gl-new-dropdown-item-stub> + <gl-new-dropdown-item-stub + avatarurl="" + iconcolor="" + iconname="" + iconrightname="" + ischeckitem="true" + secondarytext="" + > + Version + 1 + + </gl-new-dropdown-item-stub> +</gl-new-dropdown-stub> `; diff --git a/spec/frontend/design_management/components/upload/design_dropzone_spec.js b/spec/frontend/design_management/components/upload/design_dropzone_spec.js index 9b86b5b2878..bf97399368f 100644 --- a/spec/frontend/design_management/components/upload/design_dropzone_spec.js +++ b/spec/frontend/design_management/components/upload/design_dropzone_spec.js @@ -1,6 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import DesignDropzone from '~/design_management/components/upload/design_dropzone.vue'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); @@ -12,10 +13,16 @@ describe('Design management dropzone component', () => { }; const findDropzoneCard = () => wrapper.find('.design-dropzone-card'); + const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); + const findIcon = () => wrapper.find(GlIcon); - function createComponent({ slots = {}, data = {} } = {}) { + function createComponent({ slots = {}, data = {}, props = {} } = {}) { wrapper = shallowMount(DesignDropzone, { slots, + propsData: { + hasDesigns: true, + ...props, + }, data() { return data; }, @@ -129,4 +136,18 @@ describe('Design management dropzone component', () => { }); }); }); + + it('applies correct classes when there are no designs or no design saving loader', () => { + createComponent({ props: { hasDesigns: false } }); + expect(findDropzoneArea().classes()).not.toContain('gl-flex-direction-column'); + expect(findIcon().classes()).toEqual(['gl-mr-3', 'gl-text-gray-500']); + expect(findIcon().props('size')).toBe(16); + }); + + it('applies correct classes when there are designs or design saving loader', () => { + createComponent({ props: { hasDesigns: true } }); + expect(findDropzoneArea().classes()).toContain('gl-flex-direction-column'); + expect(findIcon().classes()).toEqual(['gl-mb-2']); + expect(findIcon().props('size')).toBe(24); + }); }); 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 7521b9fad2a..f4206cdaeb3 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,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlNewDropdown, GlNewDropdownItem, GlSprintf } from '@gitlab/ui'; import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import mockAllVersions from './mock_data/all_versions'; const LATEST_VERSION_ID = 3; @@ -30,7 +30,7 @@ describe('Design management design version dropdown component', () => { mocks: { $route, }, - stubs: ['router-link'], + stubs: { GlSprintf }, }); wrapper.setData({ @@ -42,7 +42,7 @@ describe('Design management design version dropdown component', () => { wrapper.destroy(); }); - const findVersionLink = index => wrapper.findAll('.js-version-link').at(index); + const findVersionLink = index => wrapper.findAll(GlNewDropdownItem).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(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + expect(wrapper.find(GlNewDropdown).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(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + expect(wrapper.find(GlNewDropdown).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(GlDropdown).attributes('text')).toBe(`Showing Version #1`); + expect(wrapper.find(GlNewDropdown).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(GlDropdown).attributes('text')).toBe('Showing Latest Version'); + expect(wrapper.find(GlNewDropdown).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(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + expect(wrapper.findAll(GlNewDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); }); }); }); diff --git a/spec/frontend/design_management/components/upload/mock_data/all_versions.js b/spec/frontend/design_management/components/upload/mock_data/all_versions.js index e76bbd261bd..237e1654f9b 100644 --- a/spec/frontend/design_management/components/upload/mock_data/all_versions.js +++ b/spec/frontend/design_management/components/upload/mock_data/all_versions.js @@ -1,14 +1,10 @@ export default [ { - node: { - id: 'gid://gitlab/DesignManagement::Version/3', - sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55', - }, + id: 'gid://gitlab/DesignManagement::Version/3', + sha: '0945756378e0b1588b9dd40d5a6b99e8b7198f55', }, { - node: { - id: 'gid://gitlab/DesignManagement::Version/2', - sha: '5b063fef0cd7213b312db65b30e24f057df21b20', - }, + id: 'gid://gitlab/DesignManagement::Version/2', + sha: '5b063fef0cd7213b312db65b30e24f057df21b20', }, ]; diff --git a/spec/frontend/design_management/mock_data/all_versions.js b/spec/frontend/design_management/mock_data/all_versions.js index c389fdb8747..2b216574e27 100644 --- a/spec/frontend/design_management/mock_data/all_versions.js +++ b/spec/frontend/design_management/mock_data/all_versions.js @@ -1,8 +1,6 @@ export default [ { - node: { - id: 'gid://gitlab/DesignManagement::Version/1', - sha: 'b389071a06c153509e11da1f582005b316667001', - }, + id: 'gid://gitlab/DesignManagement::Version/1', + sha: 'b389071a06c153509e11da1f582005b316667001', }, ]; diff --git a/spec/frontend/design_management/mock_data/apollo_mock.js b/spec/frontend/design_management/mock_data/apollo_mock.js new file mode 100644 index 00000000000..5e2df3877a5 --- /dev/null +++ b/spec/frontend/design_management/mock_data/apollo_mock.js @@ -0,0 +1,106 @@ +export const designListQueryResponse = { + data: { + project: { + id: '1', + issue: { + designCollection: { + designs: { + nodes: [ + { + id: '1', + event: 'NONE', + filename: 'fox_1.jpg', + notesCount: 3, + image: 'image-1', + imageV432x230: 'image-1', + }, + { + id: '2', + event: 'NONE', + filename: 'fox_2.jpg', + notesCount: 2, + image: 'image-2', + imageV432x230: 'image-2', + }, + { + id: '3', + event: 'NONE', + filename: 'fox_3.jpg', + notesCount: 1, + image: 'image-3', + imageV432x230: 'image-3', + }, + ], + }, + versions: { + nodes: [], + }, + }, + }, + }, + }, +}; + +export const permissionsQueryResponse = { + data: { + project: { + id: '1', + issue: { + userPermissions: { createDesign: true }, + }, + }, + }, +}; + +export const reorderedDesigns = [ + { + id: '2', + event: 'NONE', + filename: 'fox_2.jpg', + notesCount: 2, + image: 'image-2', + imageV432x230: 'image-2', + }, + { + id: '1', + event: 'NONE', + filename: 'fox_1.jpg', + notesCount: 3, + image: 'image-1', + imageV432x230: 'image-1', + }, + { + id: '3', + event: 'NONE', + filename: 'fox_3.jpg', + notesCount: 1, + image: 'image-3', + imageV432x230: 'image-3', + }, +]; + +export const moveDesignMutationResponse = { + data: { + designManagementMove: { + designCollection: { + designs: { + nodes: [...reorderedDesigns], + }, + }, + errors: [], + }, + }, +}; + +export const moveDesignMutationResponseWithErrors = { + data: { + designManagementMove: { + designCollection: { + designs: { + nodes: [...reorderedDesigns], + }, + }, + errors: ['Houston, we have a problem'], + }, + }, +}; diff --git a/spec/frontend/design_management/mock_data/design.js b/spec/frontend/design_management/mock_data/design.js index 675198b9408..72be33fef1d 100644 --- a/spec/frontend/design_management/mock_data/design.js +++ b/spec/frontend/design_management/mock_data/design.js @@ -12,14 +12,12 @@ export default { webPath: 'full-issue-path', webUrl: 'full-issue-url', participants: { - edges: [ + nodes: [ { - node: { - name: 'Administrator', - username: 'root', - webUrl: 'link-to-author', - avatarUrl: 'link-to-avatar', - }, + name: 'Administrator', + username: 'root', + webUrl: 'link-to-author', + avatarUrl: 'link-to-avatar', }, ], }, diff --git a/spec/frontend/design_management/mock_data/designs.js b/spec/frontend/design_management/mock_data/designs.js index 07f5c1b7457..98a24081ae6 100644 --- a/spec/frontend/design_management/mock_data/designs.js +++ b/spec/frontend/design_management/mock_data/designs.js @@ -5,11 +5,7 @@ export default { issue: { designCollection: { designs: { - edges: [ - { - node: design, - }, - ], + nodes: [design], }, }, }, diff --git a/spec/frontend/design_management/mock_data/no_designs.js b/spec/frontend/design_management/mock_data/no_designs.js index 9db0ffcade2..0ccb83492fc 100644 --- a/spec/frontend/design_management/mock_data/no_designs.js +++ b/spec/frontend/design_management/mock_data/no_designs.js @@ -3,7 +3,7 @@ export default { issue: { designCollection: { designs: { - edges: [], + nodes: [], }, }, }, diff --git a/spec/frontend/design_management/mock_data/versions_list.js b/spec/frontend/design_management/mock_data/versions_list.js new file mode 100644 index 00000000000..e69de29bb2d --- /dev/null +++ b/spec/frontend/design_management/mock_data/versions_list.js 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 3ba63fd14f0..3881b2d7679 100644 --- a/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management/pages/__snapshots__/index_spec.js.snap @@ -1,7 +1,10 @@ // 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="gl-mt-5" + data-testid="designs-root" +> <!----> <div @@ -11,18 +14,22 @@ exports[`Design management index page designs does not render toolbar when there class="list-unstyled row" > <li - class="col-md-6 col-lg-4 mb-3" + class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" + data-testid="design-dropzone-wrapper" > <design-dropzone-stub - class="design-list-item" + class="design-list-item design-list-item-new" + hasdesigns="true" /> </li> - <li - class="col-md-6 col-lg-4 mb-3" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > - <design-dropzone-stub> + <design-dropzone-stub + hasdesigns="true" + > <design-stub + class="gl-bg-white" event="NONE" filename="design-1-name" id="design-1" @@ -34,10 +41,13 @@ exports[`Design management index page designs does not render toolbar when there <!----> </li> <li - class="col-md-6 col-lg-4 mb-3" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > - <design-dropzone-stub> + <design-dropzone-stub + hasdesigns="true" + > <design-stub + class="gl-bg-white" event="NONE" filename="design-2-name" id="design-2" @@ -49,10 +59,13 @@ exports[`Design management index page designs does not render toolbar when there <!----> </li> <li - class="col-md-6 col-lg-4 mb-3" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > - <design-dropzone-stub> + <design-dropzone-stub + hasdesigns="true" + > <design-stub + class="gl-bg-white" event="NONE" filename="design-3-name" id="design-3" @@ -73,35 +86,50 @@ exports[`Design management index page designs does not render toolbar when there `; exports[`Design management index page designs renders designs list and header with upload button 1`] = ` -<div> +<div + class="gl-mt-5" + data-testid="designs-root" +> <header class="row-content-block border-top-0 p-2 d-flex" > <div - class="d-flex justify-content-between align-items-center w-100" + class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full" > - <design-version-dropdown-stub /> + <div> + <span + class="gl-font-weight-bold gl-mr-3" + > + Designs + </span> + + <design-version-dropdown-stub /> + </div> <div - class="qa-selector-toolbar d-flex" + class="qa-selector-toolbar gl-display-flex gl-align-items-center" > - <gl-deprecated-button-stub - class="mr-2 js-select-all" - size="md" + <gl-button-stub + category="primary" + class="gl-mr-3 js-select-all" + icon="" + size="small" variant="link" > Select all - </gl-deprecated-button-stub> + + </gl-button-stub> <div> <delete-button-stub - buttonclass="btn-danger btn-inverted mr-2" - buttonvariant="" + buttoncategory="secondary" + buttonclass="gl-mr-3" + buttonsize="small" + buttonvariant="warning" > - Delete selected - - <!----> + Archive selected + </delete-button-stub> </div> @@ -117,18 +145,22 @@ exports[`Design management index page designs renders designs list and header wi class="list-unstyled row" > <li - class="col-md-6 col-lg-4 mb-3" + class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" + data-testid="design-dropzone-wrapper" > <design-dropzone-stub - class="design-list-item" + class="design-list-item design-list-item-new" + hasdesigns="true" /> </li> - <li - class="col-md-6 col-lg-4 mb-3" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > - <design-dropzone-stub> + <design-dropzone-stub + hasdesigns="true" + > <design-stub + class="gl-bg-white" event="NONE" filename="design-1-name" id="design-1" @@ -143,10 +175,13 @@ exports[`Design management index page designs renders designs list and header wi /> </li> <li - class="col-md-6 col-lg-4 mb-3" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > - <design-dropzone-stub> + <design-dropzone-stub + hasdesigns="true" + > <design-stub + class="gl-bg-white" event="NONE" filename="design-2-name" id="design-2" @@ -161,10 +196,13 @@ exports[`Design management index page designs renders designs list and header wi /> </li> <li - class="col-md-6 col-lg-4 mb-3" + class="col-md-6 col-lg-3 gl-mb-3 gl-bg-transparent gl-shadow-none js-design-tile" > - <design-dropzone-stub> + <design-dropzone-stub + hasdesigns="true" + > <design-stub + class="gl-bg-white" event="NONE" filename="design-3-name" id="design-3" @@ -188,7 +226,10 @@ exports[`Design management index page designs renders designs list and header wi `; exports[`Design management index page designs renders error 1`] = ` -<div> +<div + class="gl-mt-5" + data-testid="designs-root" +> <!----> <div @@ -216,7 +257,10 @@ exports[`Design management index page designs renders error 1`] = ` `; exports[`Design management index page designs renders loading icon 1`] = ` -<div> +<div + class="gl-mt-5" + data-testid="designs-root" +> <!----> <div @@ -235,8 +279,11 @@ exports[`Design management index page designs renders loading icon 1`] = ` </div> `; -exports[`Design management index page when has no designs renders empty text 1`] = ` -<div> +exports[`Design management index page when has no designs renders design dropzone 1`] = ` +<div + class="gl-mt-5" + data-testid="designs-root" +> <!----> <div @@ -246,13 +293,13 @@ exports[`Design management index page when has no designs renders empty text 1`] class="list-unstyled row" > <li - class="col-md-6 col-lg-4 mb-3" + class="col-12" + data-testid="design-dropzone-wrapper" > <design-dropzone-stub - class="design-list-item" + class="" /> </li> - </ol> </div> 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 65c4811536e..823294efc38 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 @@ -10,7 +10,7 @@ exports[`Design management design index page renders design index 1`] = ` <design-destroyer-stub filenames="test.jpg" iid="1" - projectpath="" + project-path="project-path" /> <!----> @@ -41,7 +41,7 @@ exports[`Design management design index page renders design index 1`] = ` </h2> <a - class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" href="full-issue-url" > ull-issue-path @@ -60,13 +60,13 @@ exports[`Design management design index page renders design index 1`] = ` designid="test" discussion="[object Object]" discussionwithopenform="" - markdownpreviewpath="//preview_markdown?target_type=Issue" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" noteableid="design-id" /> <gl-button-stub - category="tertiary" - class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + 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" @@ -108,7 +108,7 @@ exports[`Design management design index page renders design index 1`] = ` designid="test" discussion="[object Object]" discussionwithopenform="" - markdownpreviewpath="//preview_markdown?target_type=Issue" + markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" noteableid="design-id" /> </gl-collapse-stub> @@ -140,7 +140,7 @@ exports[`Design management design index page with error GlAlert is rendered in c <design-destroyer-stub filenames="test.jpg" iid="1" - projectpath="" + project-path="project-path" /> <div @@ -188,7 +188,7 @@ exports[`Design management design index page with error GlAlert is rendered in c </h2> <a - class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" href="full-issue-url" > ull-issue-path diff --git a/spec/frontend/design_management/pages/design/index_spec.js b/spec/frontend/design_management/pages/design/index_spec.js index 82b607eb77d..369c8667f4d 100644 --- a/spec/frontend/design_management/pages/design/index_spec.js +++ b/spec/frontend/design_management/pages/design/index_spec.js @@ -2,7 +2,7 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueRouter from 'vue-router'; import { GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; 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'; @@ -95,9 +95,12 @@ describe('Design management design index page', () => { DesignSidebar, DesignReplyForm, }, + provide: { + issueIid: '1', + projectPath: 'project-path', + }, data() { return { - issueIid: '1', activeDiscussion: { id: null, source: null, @@ -149,7 +152,7 @@ describe('Design management design index page', () => { expect(findSidebar().props()).toEqual({ design, - markdownPreviewPath: '//preview_markdown?target_type=Issue', + markdownPreviewPath: '/project-path/preview_markdown?target_type=Issue', resolvedDiscussionsExpanded: false, }); }); diff --git a/spec/frontend/design_management/pages/index_apollo_spec.js b/spec/frontend/design_management/pages/index_apollo_spec.js new file mode 100644 index 00000000000..3ea711c2cfa --- /dev/null +++ b/spec/frontend/design_management/pages/index_apollo_spec.js @@ -0,0 +1,162 @@ +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 d3761bf09e9..093fa155d2e 100644 --- a/spec/frontend/design_management/pages/index_spec.js +++ b/spec/frontend/design_management/pages/index_spec.js @@ -1,5 +1,6 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; +import VueDraggable from 'vuedraggable'; import VueRouter from 'vue-router'; import { GlEmptyState } from '@gitlab/ui'; import Index from '~/design_management/pages/index.vue'; @@ -12,7 +13,7 @@ import { EXISTING_DESIGN_DROP_MANY_FILES_MESSAGE, EXISTING_DESIGN_DROP_INVALID_FILENAME_MESSAGE, } from '~/design_management/utils/error_messages'; -import createFlash from '~/flash'; +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'; @@ -25,6 +26,9 @@ const mockPageEl = { }; jest.spyOn(utils, 'getPageLayoutElement').mockReturnValue(mockPageEl); +const scrollIntoViewMock = jest.fn(); +HTMLElement.prototype.scrollIntoView = scrollIntoViewMock; + const localVue = createLocalVue(); const router = createRouter(); localVue.use(VueRouter); @@ -54,9 +58,7 @@ const mockDesigns = [ ]; const mockVersion = { - node: { - id: 'gid://gitlab/DesignManagement::Version/1', - }, + id: 'gid://gitlab/DesignManagement::Version/1', }; describe('Design management index page', () => { @@ -68,7 +70,10 @@ describe('Design management index page', () => { const findToolbar = () => wrapper.find('.qa-selector-toolbar'); const findDeleteButton = () => wrapper.find(DeleteButton); const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); + const dropzoneClasses = () => findDropzone().classes(); + const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]'); const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); + const findDesignsWrapper = () => wrapper.find('[data-testid="designs-root"]'); function createComponent({ loading = false, @@ -92,19 +97,23 @@ describe('Design management index page', () => { }; wrapper = shallowMount(Index, { + data() { + return { + designs, + allVersions, + permissions: { + createDesign, + }, + }; + }, mocks: { $apollo }, localVue, router, - stubs: { DesignDestroyer, ApolloMutation, ...stubs }, + stubs: { DesignDestroyer, ApolloMutation, VueDraggable, ...stubs }, attachToDocument: true, - }); - - wrapper.setData({ - designs, - allVersions, - issueIid: '1', - permissions: { - createDesign, + provide: { + projectPath: 'project-path', + issueIid: '1', }, }); } @@ -117,9 +126,7 @@ describe('Design management index page', () => { it('renders loading icon', () => { createComponent({ loading: true }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.element).toMatchSnapshot(); - }); + expect(wrapper.element).toMatchSnapshot(); }); it('renders error', () => { @@ -135,25 +142,35 @@ describe('Design management index page', () => { 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); - }); + 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(); - }); + 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(); - }); + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has correct classes applied to design dropzone', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + expect(dropzoneClasses()).toContain('design-list-item'); + expect(dropzoneClasses()).toContain('design-list-item-new'); + }); + + it('has correct classes applied to dropzone wrapper', () => { + createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); + expect(findDropzoneWrapper().classes()).toEqual([ + 'gl-flex-direction-column', + 'col-md-6', + 'col-lg-3', + 'gl-mb-3', + ]); }); }); @@ -162,11 +179,20 @@ describe('Design management index page', () => { createComponent(); }); - it('renders empty text', () => + it('renders design dropzone', () => wrapper.vm.$nextTick().then(() => { expect(wrapper.element).toMatchSnapshot(); })); + it('has correct classes applied to design dropzone', () => { + expect(dropzoneClasses()).not.toContain('design-list-item'); + expect(dropzoneClasses()).not.toContain('design-list-item-new'); + }); + + it('has correct classes applied to dropzone wrapper', () => { + expect(findDropzoneWrapper().classes()).toEqual(['col-12']); + }); + it('does not render a toolbar with buttons', () => wrapper.vm.$nextTick().then(() => { expect(findToolbar().exists()).toBe(false); @@ -185,7 +211,7 @@ describe('Design management index page', () => { mutation: uploadDesignQuery, variables: { files: [{ name: 'test' }], - projectPath: '', + projectPath: 'project-path', iid: '1', }, optimisticResponse: { @@ -214,13 +240,10 @@ describe('Design management index page', () => { }, versions: { __typename: 'DesignVersionConnection', - edges: { - __typename: 'DesignVersionEdge', - node: { - __typename: 'DesignVersion', - id: expect.anything(), - sha: expect.anything(), - }, + nodes: { + __typename: 'DesignVersion', + id: expect.anything(), + sha: expect.anything(), }, }, }, @@ -231,12 +254,18 @@ describe('Design management index page', () => { }, }; - 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(); - }); + 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(); + }) + .then(() => { + expect(dropzoneClasses()).toContain('design-list-item'); + expect(dropzoneClasses()).toContain('design-list-item-new'); + }); }); it('sets isSaving', () => { @@ -384,8 +413,7 @@ describe('Design management index page', () => { it('renders toolbar buttons', () => { expect(findToolbar().exists()).toBe(true); - expect(findToolbar().classes()).toContain('d-flex'); - expect(findToolbar().classes()).not.toContain('d-none'); + expect(findToolbar().isVisible()).toBe(true); }); it('adds two designs to selected designs when their checkboxes are checked', () => { @@ -442,9 +470,9 @@ describe('Design management index page', () => { }); }); - it('on latest version when has no designs does not render toolbar buttons', () => { + it('on latest version when has no designs toolbar buttons are invisible', () => { createComponent({ designs: [], allVersions: [mockVersion] }); - expect(findToolbar().exists()).toBe(false); + expect(findToolbar().isVisible()).toBe(false); }); describe('on non-latest version', () => { @@ -482,6 +510,10 @@ describe('Design management index page', () => { }); event = new Event('paste'); + event.clipboardData = { + files: [{ name: 'image.png', type: 'image/png' }], + getData: () => 'test.png', + }; router.replace({ name: DESIGNS_ROUTE_NAME, @@ -491,43 +523,52 @@ describe('Design management index page', () => { }); }); - it('calls onUploadDesign with valid paste', () => { - event.clipboardData = { - files: [{ name: 'image.png', type: 'image/png' }], - getData: () => 'test.png', - }; - + it('does not call paste event if designs wrapper is not hovered', () => { document.dispatchEvent(event); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ - new File([{ name: 'image.png' }], 'test.png'), - ]); + expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); }); - it('renames a design if it has an image.png filename', () => { - event.clipboardData = { - files: [{ name: 'image.png', type: 'image/png' }], - getData: () => 'image.png', - }; + describe('when designs wrapper is hovered', () => { + beforeEach(() => { + findDesignsWrapper().trigger('mouseenter'); + }); - document.dispatchEvent(event); + it('calls onUploadDesign with valid paste', () => { + document.dispatchEvent(event); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); - expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ - new File([{ name: 'image.png' }], `design_${Date.now()}.png`), - ]); - }); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ + new File([{ name: 'image.png' }], 'test.png'), + ]); + }); - it('does not call onUploadDesign with invalid paste', () => { - event.clipboardData = { - items: [{ type: 'text/plain' }, { type: 'text' }], - files: [], - }; + it('renames a design if it has an image.png filename', () => { + document.dispatchEvent(event); - document.dispatchEvent(event); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledTimes(1); + expect(wrapper.vm.onUploadDesign).toHaveBeenCalledWith([ + new File([{ name: 'image.png' }], `design_${Date.now()}.png`), + ]); + }); - expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); + 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(); + }); + + it('removes onPaste listener after mouseleave event', async () => { + findDesignsWrapper().trigger('mouseleave'); + document.dispatchEvent(event); + + expect(wrapper.vm.onUploadDesign).not.toHaveBeenCalled(); + }); }); }); @@ -535,9 +576,18 @@ describe('Design management index page', () => { it('ensures fullscreen layout is not applied', () => { createComponent(true); - wrapper.vm.$router.push('/designs'); + wrapper.vm.$router.push('/'); expect(mockPageEl.classList.remove).toHaveBeenCalledTimes(1); expect(mockPageEl.classList.remove).toHaveBeenCalledWith(...DESIGN_DETAIL_LAYOUT_CLASSLIST); }); + + it('should trigger a scrollIntoView method if designs route is detected', () => { + router.replace({ + path: '/designs', + }); + createComponent(true); + + expect(scrollIntoViewMock).toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/design_management/router_spec.js b/spec/frontend/design_management/router_spec.js index d6488d3837a..2b8c7ee959b 100644 --- a/spec/frontend/design_management/router_spec.js +++ b/spec/frontend/design_management/router_spec.js @@ -5,11 +5,7 @@ import App from '~/design_management/components/app.vue'; import Designs from '~/design_management/pages/index.vue'; import DesignDetail from '~/design_management/pages/design/index.vue'; import createRouter from '~/design_management/router'; -import { - ROOT_ROUTE_NAME, - DESIGNS_ROUTE_NAME, - DESIGN_ROUTE_NAME, -} from '~/design_management/router/constants'; +import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; import '~/commons/bootstrap'; function factory(routeArg) { @@ -49,7 +45,7 @@ describe('Design management router', () => { window.location.hash = ''; }); - describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => { + describe.each([['/'], [{ name: DESIGNS_ROUTE_NAME }]])('root route', routeArg => { it('pushes home component', () => { const wrapper = factory(routeArg); @@ -57,14 +53,6 @@ describe('Design management router', () => { }); }); - 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 => { diff --git a/spec/frontend/design_management/utils/cache_update_spec.js b/spec/frontend/design_management/utils/cache_update_spec.js index 641d35ff9ff..e8a5cf3949d 100644 --- a/spec/frontend/design_management/utils/cache_update_spec.js +++ b/spec/frontend/design_management/utils/cache_update_spec.js @@ -13,7 +13,7 @@ import { UPDATE_IMAGE_DIFF_NOTE_ERROR, } from '~/design_management/utils/error_messages'; import design from '../mock_data/design'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash.js'); 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 478ebadc8f6..e6d836b9157 100644 --- a/spec/frontend/design_management/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management/utils/design_management_utils_spec.js @@ -51,7 +51,7 @@ describe('extractDiscussions', () => { }; }); - it('discards the edges.node artifacts of GraphQL', () => { + it('discards the node artifacts of GraphQL', () => { expect(extractDiscussions(discussions)).toEqual([ { id: 1, notes: ['a'], index: 1 }, { id: 2, notes: ['b'], index: 2 }, @@ -96,10 +96,7 @@ describe('optimistic responses', () => { discussions: { __typename: 'DesignDiscussion', nodes: [] }, versions: { __typename: 'DesignVersionConnection', - edges: { - __typename: 'DesignVersionEdge', - node: { __typename: 'DesignVersion', id: -1, sha: -1 }, - }, + nodes: { __typename: 'DesignVersion', id: -1, sha: -1 }, }, }, ], diff --git a/spec/frontend/design_management/utils/error_messages_spec.js b/spec/frontend/design_management/utils/error_messages_spec.js index 635ff931d7d..f5072c3b6b7 100644 --- a/spec/frontend/design_management/utils/error_messages_spec.js +++ b/spec/frontend/design_management/utils/error_messages_spec.js @@ -10,8 +10,8 @@ const mockFilenames = n => describe('Error message', () => { describe('designDeletionError', () => { - const singularMsg = 'Could not delete a design. Please try again.'; - const pluralMsg = 'Could not delete designs. Please try again.'; + const singularMsg = 'Could not archive a design. Please try again.'; + const pluralMsg = 'Could not archive designs. Please try again.'; describe('when [singular=true]', () => { it.each([[undefined], [true]])('uses singular grammar', singularOption => { @@ -55,7 +55,7 @@ describe('Error message', () => { '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) => { - test('returns expected warning message', () => { + it('returns expected warning message', () => { expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected); }); }); diff --git a/spec/frontend/design_management_new/components/__snapshots__/design_note_pin_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap index 4c848256e5b..62a0f675cff 100644 --- a/spec/frontend/design_management_new/components/__snapshots__/design_note_pin_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/__snapshots__/design_note_pin_spec.js.snap @@ -3,13 +3,13 @@ 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 btn-transparent comment-indicator" + 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" > - <icon-stub + <gl-icon-stub name="image-comment-dark" - size="16" + size="24" /> </button> `; @@ -17,7 +17,7 @@ exports[`Design note pin component should match the snapshot of note when reposi 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 js-image-badge badge badge-pill" + 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" > @@ -30,13 +30,13 @@ exports[`Design note pin component should match the snapshot of note with index 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 btn-transparent comment-indicator" + 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" > - <icon-stub + <gl-icon-stub name="image-comment-dark" - size="16" + size="24" /> </button> `; diff --git a/spec/frontend/design_management_new/components/__snapshots__/design_presentation_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap index 189962c5b2e..189962c5b2e 100644 --- a/spec/frontend/design_management_new/components/__snapshots__/design_presentation_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/__snapshots__/design_presentation_spec.js.snap diff --git a/spec/frontend/design_management_new/components/__snapshots__/design_scaler_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap index cb4575cbd11..cb4575cbd11 100644 --- a/spec/frontend/design_management_new/components/__snapshots__/design_scaler_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/__snapshots__/design_scaler_spec.js.snap diff --git a/spec/frontend/design_management_new/components/__snapshots__/image_spec.js.snap b/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap index acaa62b11eb..acaa62b11eb 100644 --- a/spec/frontend/design_management_new/components/__snapshots__/image_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/__snapshots__/image_spec.js.snap diff --git a/spec/frontend/design_management_new/components/delete_button_spec.js b/spec/frontend/design_management_legacy/components/delete_button_spec.js index 218c58847a6..73b4908d06a 100644 --- a/spec/frontend/design_management_new/components/delete_button_spec.js +++ b/spec/frontend/design_management_legacy/components/delete_button_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import { GlButton, GlModal, GlModalDirective } from '@gitlab/ui'; -import BatchDeleteButton from '~/design_management_new/components/delete_button.vue'; +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(GlButton); + const findButton = () => wrapper.find(GlDeprecatedButton); const findModal = () => wrapper.find(GlModal); function createComponent(isDeleting = false) { diff --git a/spec/frontend/design_management_new/components/design_note_pin_spec.js b/spec/frontend/design_management_legacy/components/design_note_pin_spec.js index 8e2caa604f4..3077928cf86 100644 --- a/spec/frontend/design_management_new/components/design_note_pin_spec.js +++ b/spec/frontend/design_management_legacy/components/design_note_pin_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import DesignNotePin from '~/design_management_new/components/design_note_pin.vue'; +import DesignNotePin from '~/design_management_legacy/components/design_note_pin.vue'; describe('Design note pin component', () => { let wrapper; diff --git a/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_note_spec.js.snap b/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap index b55bacb6fc5..b55bacb6fc5 100644 --- a/spec/frontend/design_management_new/components/design_notes/__snapshots__/design_note_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/design_notes/__snapshots__/design_note_spec.js.snap diff --git a/spec/frontend/design_management_new/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 index e01c79e3520..e01c79e3520 100644 --- a/spec/frontend/design_management_new/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 diff --git a/spec/frontend/design_management_new/components/design_notes/design_discussion_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js index 401ce64e859..d20be97f470 100644 --- a/spec/frontend/design_management_new/components/design_notes/design_discussion_spec.js +++ b/spec/frontend/design_management_legacy/components/design_notes/design_discussion_spec.js @@ -1,13 +1,13 @@ import { mount } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui'; import notes from '../../mock_data/notes'; -import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue'; -import DesignNote from '~/design_management_new/components/design_notes/design_note.vue'; -import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue'; -import createNoteMutation from '~/design_management_new/graphql/mutations/create_note.mutation.graphql'; -import toggleResolveDiscussionMutation from '~/design_management_new/graphql/mutations/toggle_resolve_discussion.mutation.graphql'; +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_new/components/design_notes/toggle_replies_widget.vue'; +import ToggleRepliesWidget from '~/design_management_legacy/components/design_notes/toggle_replies_widget.vue'; const discussion = { id: '0', @@ -61,10 +61,6 @@ describe('Design discussions component', () => { ...data, }; }, - provide: { - projectPath: 'project-path', - issueIid: '1', - }, mocks: { $apollo, $route: { diff --git a/spec/frontend/design_management_new/components/design_notes/design_note_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js index b0e3e85b9c6..aa187cd1388 100644 --- a/spec/frontend/design_management_new/components/design_notes/design_note_spec.js +++ b/spec/frontend/design_management_legacy/components/design_notes/design_note_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; import { ApolloMutation } from 'vue-apollo'; -import DesignNote from '~/design_management_new/components/design_notes/design_note.vue'; +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_new/components/design_notes/design_reply_form.vue'; +import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue'; const scrollIntoViewMock = jest.fn(); const note = { diff --git a/spec/frontend/design_management_new/components/design_notes/design_reply_form_spec.js b/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js index 9c1d6154516..088a71b64af 100644 --- a/spec/frontend/design_management_new/components/design_notes/design_reply_form_spec.js +++ b/spec/frontend/design_management_legacy/components/design_notes/design_reply_form_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import DesignReplyForm from '~/design_management_new/components/design_notes/design_reply_form.vue'; +import DesignReplyForm from '~/design_management_legacy/components/design_notes/design_reply_form.vue'; const showModal = jest.fn(); diff --git a/spec/frontend/design_management_new/components/design_notes/toggle_replies_widget_spec.js b/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js index d3c89075a24..acc7cbbca52 100644 --- a/spec/frontend/design_management_new/components/design_notes/toggle_replies_widget_spec.js +++ b/spec/frontend/design_management_legacy/components/design_notes/toggle_replies_widget_spec.js @@ -1,7 +1,7 @@ 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_new/components/design_notes/toggle_replies_widget.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', () => { diff --git a/spec/frontend/design_management_new/components/design_overlay_spec.js b/spec/frontend/design_management_legacy/components/design_overlay_spec.js index 4ca69c143a8..c014f3479f4 100644 --- a/spec/frontend/design_management_new/components/design_overlay_spec.js +++ b/spec/frontend/design_management_legacy/components/design_overlay_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; -import DesignOverlay from '~/design_management_new/components/design_overlay.vue'; -import updateActiveDiscussion from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql'; +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_new/constants'; +import { ACTIVE_DISCUSSION_SOURCE_TYPES } from '~/design_management_legacy/constants'; const mutate = jest.fn(() => Promise.resolve()); diff --git a/spec/frontend/design_management_new/components/design_presentation_spec.js b/spec/frontend/design_management_legacy/components/design_presentation_spec.js index d043a762cd2..ceff86b0549 100644 --- a/spec/frontend/design_management_new/components/design_presentation_spec.js +++ b/spec/frontend/design_management_legacy/components/design_presentation_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import DesignPresentation from '~/design_management_new/components/design_presentation.vue'; -import DesignOverlay from '~/design_management_new/components/design_overlay.vue'; +import DesignPresentation from '~/design_management_legacy/components/design_presentation.vue'; +import DesignOverlay from '~/design_management_legacy/components/design_overlay.vue'; const mockOverlayData = { overlayDimensions: { diff --git a/spec/frontend/design_management_new/components/design_scaler_spec.js b/spec/frontend/design_management_legacy/components/design_scaler_spec.js index 5ff2554cd60..30ef5ab159b 100644 --- a/spec/frontend/design_management_new/components/design_scaler_spec.js +++ b/spec/frontend/design_management_legacy/components/design_scaler_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import DesignScaler from '~/design_management_new/components/design_scaler.vue'; +import DesignScaler from '~/design_management_legacy/components/design_scaler.vue'; describe('Design management design scaler component', () => { let wrapper; diff --git a/spec/frontend/design_management_new/components/design_sidebar_spec.js b/spec/frontend/design_management_legacy/components/design_sidebar_spec.js index f1d442a7b21..fc0f618c359 100644 --- a/spec/frontend/design_management_new/components/design_sidebar_spec.js +++ b/spec/frontend/design_management_legacy/components/design_sidebar_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; import { GlCollapse, GlPopover } from '@gitlab/ui'; import Cookies from 'js-cookie'; -import DesignSidebar from '~/design_management_new/components/design_sidebar.vue'; +import DesignSidebar from '~/design_management_legacy/components/design_sidebar.vue'; import Participants from '~/sidebar/components/participants/participants.vue'; -import DesignDiscussion from '~/design_management_new/components/design_notes/design_discussion.vue'; +import DesignDiscussion from '~/design_management_legacy/components/design_notes/design_discussion.vue'; import design from '../mock_data/design'; -import updateActiveDiscussionMutation from '~/design_management_new/graphql/mutations/update_active_discussion.mutation.graphql'; +import updateActiveDiscussionMutation from '~/design_management_legacy/graphql/mutations/update_active_discussion.mutation.graphql'; const updateActiveDiscussionMutationVariables = { mutation: updateActiveDiscussionMutation, diff --git a/spec/frontend/design_management_new/components/image_spec.js b/spec/frontend/design_management_legacy/components/image_spec.js index c1a8a8767df..265c91abb4e 100644 --- a/spec/frontend/design_management_new/components/image_spec.js +++ b/spec/frontend/design_management_legacy/components/image_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; -import DesignImage from '~/design_management_new/components/image.vue'; +import DesignImage from '~/design_management_legacy/components/image.vue'; describe('Design management large image component', () => { let wrapper; 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 new file mode 100644 index 00000000000..168b9424006 --- /dev/null +++ b/spec/frontend/design_management_legacy/components/list/__snapshots__/item_spec.js.snap @@ -0,0 +1,149 @@ +// 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_new/components/list/item_spec.js b/spec/frontend/design_management_legacy/components/list/item_spec.js index 5e3e6832acb..e9bb0fc3f29 100644 --- a/spec/frontend/design_management_new/components/list/item_spec.js +++ b/spec/frontend/design_management_legacy/components/list/item_spec.js @@ -1,7 +1,8 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlIcon, GlLoadingIcon, GlIntersectionObserver } from '@gitlab/ui'; import VueRouter from 'vue-router'; -import Item from '~/design_management_new/components/list/item.vue'; +import Icon from '~/vue_shared/components/icon.vue'; +import Item from '~/design_management_legacy/components/list/item.vue'; const localVue = createLocalVue(); localVue.use(VueRouter); @@ -18,6 +19,10 @@ const DESIGN_VERSION_EVENT = { 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, @@ -134,35 +139,31 @@ describe('Design management list item component', () => { }); }); - describe('with no notes', () => { - it('renders item with no status icon for none event', () => { - createComponent(); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders item with correct status icon for modification event', () => { - createComponent({ event: DESIGN_VERSION_EVENT.MODIFICATION }); - - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders item with correct status icon for deletion event', () => { - createComponent({ event: DESIGN_VERSION_EVENT.DELETION }); + it('renders loading spinner when isUploading is true', () => { + createComponent({ isUploading: true }); - expect(wrapper.element).toMatchSnapshot(); - }); + expect(findLoadingIcon().exists()).toBe(true); + }); - it('renders item with correct status icon for creation event', () => { - createComponent({ event: DESIGN_VERSION_EVENT.CREATION }); + it('renders item with no status icon for none event', () => { + createComponent(); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('renders loading spinner when isUploading is true', () => { - createComponent({ isUploading: true }); + expect(findDesignEvent().exists()).toBe(false); + }); - expect(wrapper.element).toMatchSnapshot(); + 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_new/components/toolbar/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap index f251171ecda..e55cff8de3d 100644 --- a/spec/frontend/design_management_new/components/toolbar/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/index_spec.js.snap @@ -7,7 +7,6 @@ exports[`Design management toolbar component renders design and updated data 1`] <a aria-label="Go back to designs" class="mr-3 text-plain d-flex justify-content-center align-items-center" - data-testid="close-design" > <icon-stub name="close" @@ -50,7 +49,6 @@ exports[`Design management toolbar component renders design and updated data 1`] <delete-button-stub buttonclass="" - buttonsize="medium" buttonvariant="danger" hasselecteddesigns="true" > diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap index 08662a04f15..08662a04f15 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_button_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_button_spec.js.snap diff --git a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap index 0197b4bff79..0197b4bff79 100644 --- a/spec/frontend/design_management/components/toolbar/__snapshots__/pagination_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/toolbar/__snapshots__/pagination_spec.js.snap diff --git a/spec/frontend/design_management_new/components/toolbar/index_spec.js b/spec/frontend/design_management_legacy/components/toolbar/index_spec.js index eb5ae15ed58..8207cad4136 100644 --- a/spec/frontend/design_management_new/components/toolbar/index_spec.js +++ b/spec/frontend/design_management_legacy/components/toolbar/index_spec.js @@ -1,9 +1,9 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueRouter from 'vue-router'; -import Toolbar from '~/design_management_new/components/toolbar/index.vue'; -import DeleteButton from '~/design_management_new/components/delete_button.vue'; -import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants'; 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); diff --git a/spec/frontend/design_management_new/components/toolbar/pagination_button_spec.js b/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js index 5f33d65fc1f..d2153adca45 100644 --- a/spec/frontend/design_management_new/components/toolbar/pagination_button_spec.js +++ b/spec/frontend/design_management_legacy/components/toolbar/pagination_button_spec.js @@ -1,7 +1,7 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import VueRouter from 'vue-router'; -import PaginationButton from '~/design_management_new/components/toolbar/pagination_button.vue'; -import { DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants'; +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); diff --git a/spec/frontend/design_management/components/toolbar/pagination_spec.js b/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js index db5a36dadf6..21b55113a6e 100644 --- a/spec/frontend/design_management/components/toolbar/pagination_spec.js +++ b/spec/frontend/design_management_legacy/components/toolbar/pagination_spec.js @@ -1,8 +1,8 @@ /* global Mousetrap */ import 'mousetrap'; import { shallowMount } from '@vue/test-utils'; -import Pagination from '~/design_management/components/toolbar/pagination.vue'; -import { DESIGN_ROUTE_NAME } from '~/design_management/router/constants'; +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 = { diff --git a/spec/frontend/design_management_new/components/upload/__snapshots__/button_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap index b498becc606..27c0ba589e6 100644 --- a/spec/frontend/design_management_new/components/upload/__snapshots__/button_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/upload/__snapshots__/button_spec.js.snap @@ -4,10 +4,8 @@ exports[`Design management upload button component renders inverted upload desig <div isinverted="true" > - <gl-button-stub - category="tertiary" - icon="" - size="small" + <gl-deprecated-button-stub + size="md" title="Adding a design with the same filename replaces the file in a new version." variant="success" > @@ -15,7 +13,7 @@ exports[`Design management upload button component renders inverted upload desig Upload designs <!----> - </gl-button-stub> + </gl-deprecated-button-stub> <input accept="image/*" @@ -29,11 +27,9 @@ exports[`Design management upload button component renders inverted upload desig exports[`Design management upload button component renders loading icon 1`] = ` <div> - <gl-button-stub - category="tertiary" + <gl-deprecated-button-stub disabled="true" - icon="" - size="small" + size="md" title="Adding a design with the same filename replaces the file in a new version." variant="success" > @@ -47,7 +43,7 @@ exports[`Design management upload button component renders loading icon 1`] = ` label="Loading" size="sm" /> - </gl-button-stub> + </gl-deprecated-button-stub> <input accept="image/*" @@ -61,10 +57,8 @@ exports[`Design management upload button component renders loading icon 1`] = ` exports[`Design management upload button component renders upload design button 1`] = ` <div> - <gl-button-stub - category="tertiary" - icon="" - size="small" + <gl-deprecated-button-stub + size="md" title="Adding a design with the same filename replaces the file in a new version." variant="success" > @@ -72,7 +66,7 @@ exports[`Design management upload button component renders upload design button Upload designs <!----> - </gl-button-stub> + </gl-deprecated-button-stub> <input accept="image/*" diff --git a/spec/frontend/design_management_new/components/upload/__snapshots__/design_dropzone_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap index c53c6c889b0..0737b9729a2 100644 --- a/spec/frontend/design_management_new/components/upload/__snapshots__/design_dropzone_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_dropzone_spec.js.snap @@ -5,23 +5,20 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" > <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" - data-testid="dropzone-area" + class="d-flex-center flex-column text-center" > <gl-icon-stub - class="gl-mb-2" - name="upload" - size="24" + class="mb-4" + name="doc-new" + size="48" /> - <p - class="gl-font-weight-bold gl-mb-0" - > + <p> <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." /> </p> </div> @@ -46,9 +43,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -61,9 +56,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="" > - <h3 - class="" - > + <h3> Incoming! </h3> @@ -81,23 +74,20 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" > <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" - data-testid="dropzone-area" + class="d-flex-center flex-column text-center" > <gl-icon-stub - class="gl-mb-2" - name="upload" - size="24" + class="mb-4" + name="doc-new" + size="48" /> - <p - class="gl-font-weight-bold gl-mb-0" - > + <p> <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." /> </p> </div> @@ -122,9 +112,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -137,9 +125,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="" > - <h3 - class="" - > + <h3> Incoming! </h3> @@ -157,23 +143,20 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" > <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" - data-testid="dropzone-area" + class="d-flex-center flex-column text-center" > <gl-icon-stub - class="gl-mb-2" - name="upload" - size="24" + class="mb-4" + name="doc-new" + size="48" /> - <p - class="gl-font-weight-bold gl-mb-0" - > + <p> <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." /> </p> </div> @@ -197,9 +180,7 @@ exports[`Design management dropzone component when dragging renders correct temp <div class="mw-50 text-center" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -212,9 +193,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Incoming! </h3> @@ -232,23 +211,20 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" > <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" - data-testid="dropzone-area" + class="d-flex-center flex-column text-center" > <gl-icon-stub - class="gl-mb-2" - name="upload" - size="24" + class="mb-4" + name="doc-new" + size="48" /> - <p - class="gl-font-weight-bold gl-mb-0" - > + <p> <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." /> </p> </div> @@ -272,9 +248,7 @@ exports[`Design management dropzone component when dragging renders correct temp <div class="mw-50 text-center" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -287,9 +261,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Incoming! </h3> @@ -307,23 +279,20 @@ exports[`Design management dropzone component when dragging renders correct temp class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" > <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" - data-testid="dropzone-area" + class="d-flex-center flex-column text-center" > <gl-icon-stub - class="gl-mb-2" - name="upload" - size="24" + class="mb-4" + name="doc-new" + size="48" /> - <p - class="gl-font-weight-bold gl-mb-0" - > + <p> <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." /> </p> </div> @@ -347,9 +316,7 @@ exports[`Design management dropzone component when dragging renders correct temp <div class="mw-50 text-center" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -362,9 +329,7 @@ exports[`Design management dropzone component when dragging renders correct temp class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Incoming! </h3> @@ -382,23 +347,20 @@ exports[`Design management dropzone component when no slot provided renders defa class="w-100 position-relative" > <button - class="card design-dropzone-card design-dropzone-border w-100 h-100 gl-align-items-center gl-justify-content-center gl-p-3" + class="card design-dropzone-card design-dropzone-border w-100 h-100 d-flex-center p-3" > <div - class="gl-display-flex gl-align-items-center gl-justify-content-center gl-text-center gl-flex-direction-column" - data-testid="dropzone-area" + class="d-flex-center flex-column text-center" > <gl-icon-stub - class="gl-mb-2" - name="upload" - size="24" + class="mb-4" + name="doc-new" + size="48" /> - <p - class="gl-font-weight-bold gl-mb-0" - > + <p> <gl-sprintf-stub - message="Drop or %{linkStart}upload%{linkEnd} Designs to attach" + message="%{lineOneStart}Drag and drop to upload your designs%{lineOneEnd} or %{linkStart}click to upload%{linkEnd}." /> </p> </div> @@ -422,9 +384,7 @@ exports[`Design management dropzone component when no slot provided renders defa <div class="mw-50 text-center" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -437,9 +397,7 @@ exports[`Design management dropzone component when no slot provided renders defa class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Incoming! </h3> @@ -470,9 +428,7 @@ exports[`Design management dropzone component when slot provided renders dropzon <div class="mw-50 text-center" > - <h3 - class="" - > + <h3> Oh no! </h3> @@ -485,9 +441,7 @@ exports[`Design management dropzone component when slot provided renders dropzon class="mw-50 text-center" style="display: none;" > - <h3 - class="" - > + <h3> Incoming! </h3> diff --git a/spec/frontend/design_management_new/components/upload/__snapshots__/design_version_dropdown_spec.js.snap b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap index 0d16acdef54..d34b925f33d 100644 --- a/spec/frontend/design_management_new/components/upload/__snapshots__/design_version_dropdown_spec.js.snap +++ b/spec/frontend/design_management_legacy/components/upload/__snapshots__/design_version_dropdown_spec.js.snap @@ -1,23 +1,14 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` -<gl-new-dropdown-stub - category="tertiary" +<gl-deprecated-dropdown-stub class="design-version-dropdown" - headertext="" issueiid="" projectpath="" - size="small" text="Showing Latest Version" - variant="default" + variant="link" > - <gl-new-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightname="" - secondarytext="" - > + <gl-deprecated-dropdown-item-stub> <router-link-stub class="d-flex js-version-link" to="[object Object]" @@ -37,17 +28,11 @@ exports[`Design management design version dropdown component renders design vers </div> <i - class="fa fa-check pull-right" + class="fa fa-check float-right gl-mr-2" /> </router-link-stub> - </gl-new-dropdown-item-stub> - <gl-new-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightname="" - secondarytext="" - > + </gl-deprecated-dropdown-item-stub> + <gl-deprecated-dropdown-item-stub> <router-link-stub class="d-flex js-version-link" to="[object Object]" @@ -66,28 +51,19 @@ exports[`Design management design version dropdown component renders design vers <!----> </router-link-stub> - </gl-new-dropdown-item-stub> -</gl-new-dropdown-stub> + </gl-deprecated-dropdown-item-stub> +</gl-deprecated-dropdown-stub> `; exports[`Design management design version dropdown component renders design version list 1`] = ` -<gl-new-dropdown-stub - category="tertiary" +<gl-deprecated-dropdown-stub class="design-version-dropdown" - headertext="" issueiid="" projectpath="" - size="small" text="Showing Latest Version" - variant="default" + variant="link" > - <gl-new-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightname="" - secondarytext="" - > + <gl-deprecated-dropdown-item-stub> <router-link-stub class="d-flex js-version-link" to="[object Object]" @@ -107,17 +83,11 @@ exports[`Design management design version dropdown component renders design vers </div> <i - class="fa fa-check pull-right" + class="fa fa-check float-right gl-mr-2" /> </router-link-stub> - </gl-new-dropdown-item-stub> - <gl-new-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightname="" - secondarytext="" - > + </gl-deprecated-dropdown-item-stub> + <gl-deprecated-dropdown-item-stub> <router-link-stub class="d-flex js-version-link" to="[object Object]" @@ -136,6 +106,6 @@ exports[`Design management design version dropdown component renders design vers <!----> </router-link-stub> - </gl-new-dropdown-item-stub> -</gl-new-dropdown-stub> + </gl-deprecated-dropdown-item-stub> +</gl-deprecated-dropdown-stub> `; diff --git a/spec/frontend/design_management_new/components/upload/button_spec.js b/spec/frontend/design_management_legacy/components/upload/button_spec.js index 7f751982491..dde5c694194 100644 --- a/spec/frontend/design_management_new/components/upload/button_spec.js +++ b/spec/frontend/design_management_legacy/components/upload/button_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import UploadButton from '~/design_management_new/components/upload/button.vue'; +import UploadButton from '~/design_management_legacy/components/upload/button.vue'; describe('Design management upload button component', () => { let wrapper; diff --git a/spec/frontend/design_management_new/components/upload/design_dropzone_spec.js b/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js index c48cbb10172..1907a3124a6 100644 --- a/spec/frontend/design_management_new/components/upload/design_dropzone_spec.js +++ b/spec/frontend/design_management_legacy/components/upload/design_dropzone_spec.js @@ -1,7 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import DesignDropzone from '~/design_management_new/components/upload/design_dropzone.vue'; -import createFlash from '~/flash'; -import { GlIcon } from '@gitlab/ui'; +import DesignDropzone from '~/design_management_legacy/components/upload/design_dropzone.vue'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); @@ -13,16 +12,10 @@ describe('Design management dropzone component', () => { }; const findDropzoneCard = () => wrapper.find('.design-dropzone-card'); - const findDropzoneArea = () => wrapper.find('[data-testid="dropzone-area"]'); - const findIcon = () => wrapper.find(GlIcon); - function createComponent({ slots = {}, data = {}, props = {} } = {}) { + function createComponent({ slots = {}, data = {} } = {}) { wrapper = shallowMount(DesignDropzone, { slots, - propsData: { - hasDesigns: true, - ...props, - }, data() { return data; }, @@ -136,16 +129,4 @@ describe('Design management dropzone component', () => { }); }); }); - - it('applies correct classes when there are no designs or no design saving loader', () => { - createComponent({ props: { hasDesigns: false } }); - expect(findDropzoneArea().classes()).not.toContain('gl-flex-direction-column'); - expect(findIcon().classes()).toEqual(['gl-mr-4']); - }); - - it('applies correct classes when there are designs or design saving loader', () => { - createComponent({ props: { hasDesigns: true } }); - expect(findDropzoneArea().classes()).toContain('gl-flex-direction-column'); - expect(findIcon().classes()).toEqual(['gl-mb-2']); - }); }); diff --git a/spec/frontend/design_management_new/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js index 74e7f3f88fc..7fb85f357c7 100644 --- a/spec/frontend/design_management_new/components/upload/design_version_dropdown_spec.js +++ b/spec/frontend/design_management_legacy/components/upload/design_version_dropdown_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import DesignVersionDropdown from '~/design_management_new/components/upload/design_version_dropdown.vue'; -import { GlNewDropdown, GlNewDropdownItem } from '@gitlab/ui'; +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; @@ -75,7 +75,9 @@ 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(GlDeprecatedDropdown).attributes('text')).toBe( + 'Showing Latest Version', + ); }); }); @@ -83,7 +85,9 @@ 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(GlDeprecatedDropdown).attributes('text')).toBe( + 'Showing Latest Version', + ); }); }); @@ -91,7 +95,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(GlDeprecatedDropdown).attributes('text')).toBe(`Showing Version #1`); }); }); @@ -99,7 +103,9 @@ 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(GlDeprecatedDropdown).attributes('text')).toBe( + 'Showing Latest Version', + ); }); }); @@ -107,7 +113,9 @@ 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(GlDeprecatedDropdownItem)).toHaveLength( + wrapper.vm.allVersions.length, + ); }); }); }); diff --git a/spec/frontend/design_management_new/components/upload/mock_data/all_versions.js b/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js index e76bbd261bd..e76bbd261bd 100644 --- a/spec/frontend/design_management_new/components/upload/mock_data/all_versions.js +++ b/spec/frontend/design_management_legacy/components/upload/mock_data/all_versions.js diff --git a/spec/frontend/design_management_new/mock_data/all_versions.js b/spec/frontend/design_management_legacy/mock_data/all_versions.js index c389fdb8747..c389fdb8747 100644 --- a/spec/frontend/design_management_new/mock_data/all_versions.js +++ b/spec/frontend/design_management_legacy/mock_data/all_versions.js diff --git a/spec/frontend/design_management_new/mock_data/design.js b/spec/frontend/design_management_legacy/mock_data/design.js index 675198b9408..675198b9408 100644 --- a/spec/frontend/design_management_new/mock_data/design.js +++ b/spec/frontend/design_management_legacy/mock_data/design.js diff --git a/spec/frontend/design_management_new/mock_data/designs.js b/spec/frontend/design_management_legacy/mock_data/designs.js index 07f5c1b7457..07f5c1b7457 100644 --- a/spec/frontend/design_management_new/mock_data/designs.js +++ b/spec/frontend/design_management_legacy/mock_data/designs.js diff --git a/spec/frontend/design_management_new/mock_data/no_designs.js b/spec/frontend/design_management_legacy/mock_data/no_designs.js index 9db0ffcade2..9db0ffcade2 100644 --- a/spec/frontend/design_management_new/mock_data/no_designs.js +++ b/spec/frontend/design_management_legacy/mock_data/no_designs.js diff --git a/spec/frontend/design_management_new/mock_data/notes.js b/spec/frontend/design_management_legacy/mock_data/notes.js index 80cb3944786..80cb3944786 100644 --- a/spec/frontend/design_management_new/mock_data/notes.js +++ b/spec/frontend/design_management_legacy/mock_data/notes.js diff --git a/spec/frontend/design_management_new/pages/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap index 902803b0ad1..3ba63fd14f0 100644 --- a/spec/frontend/design_management_new/pages/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management_legacy/pages/__snapshots__/index_spec.js.snap @@ -1,10 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design management index page designs does not render toolbar when there is no permission 1`] = ` -<div - class="gl-mt-5 designs-root" - data-testid="designs-root" -> +<div> <!----> <div @@ -13,24 +10,18 @@ exports[`Design management index page designs does not render toolbar when there <ol class="list-unstyled row" > - <!----> - <li - class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" - data-testid="design-dropzone-wrapper" + class="col-md-6 col-lg-4 mb-3" > <design-dropzone-stub - class="design-list-item design-list-item-new" - hasdesigns="true" + class="design-list-item" /> </li> <li - class="col-md-6 col-lg-3 gl-mb-3" + class="col-md-6 col-lg-4 mb-3" > - <design-dropzone-stub - hasdesigns="true" - > + <design-dropzone-stub> <design-stub event="NONE" filename="design-1-name" @@ -43,11 +34,9 @@ exports[`Design management index page designs does not render toolbar when there <!----> </li> <li - class="col-md-6 col-lg-3 gl-mb-3" + class="col-md-6 col-lg-4 mb-3" > - <design-dropzone-stub - hasdesigns="true" - > + <design-dropzone-stub> <design-stub event="NONE" filename="design-2-name" @@ -60,11 +49,9 @@ exports[`Design management index page designs does not render toolbar when there <!----> </li> <li - class="col-md-6 col-lg-3 gl-mb-3" + class="col-md-6 col-lg-4 mb-3" > - <design-dropzone-stub - hasdesigns="true" - > + <design-dropzone-stub> <design-stub event="NONE" filename="design-3-name" @@ -86,45 +73,30 @@ exports[`Design management index page designs does not render toolbar when there `; exports[`Design management index page designs renders designs list and header with upload button 1`] = ` -<div - class="gl-mt-5 designs-root" - data-testid="designs-root" -> +<div> <header class="row-content-block border-top-0 p-2 d-flex" > <div - class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-w-full" + class="d-flex justify-content-between align-items-center w-100" > - <div> - <span - class="gl-font-weight-bold gl-mr-3" - > - Designs - </span> - - <design-version-dropdown-stub /> - </div> + <design-version-dropdown-stub /> <div - class="qa-selector-toolbar gl-display-flex" + class="qa-selector-toolbar d-flex" > - <gl-button-stub - category="tertiary" - class="gl-mr-2 js-select-all" - icon="" - size="small" + <gl-deprecated-button-stub + class="mr-2 js-select-all" + size="md" variant="link" > Select all - - </gl-button-stub> + </gl-deprecated-button-stub> <div> <delete-button-stub - buttonclass="gl-mr-4" - buttonsize="small" - buttonvariant="danger" + buttonclass="btn-danger btn-inverted mr-2" + buttonvariant="" > Delete selected @@ -144,24 +116,18 @@ exports[`Design management index page designs renders designs list and header wi <ol class="list-unstyled row" > - <!----> - <li - class="gl-flex-direction-column col-md-6 col-lg-3 gl-mb-3" - data-testid="design-dropzone-wrapper" + class="col-md-6 col-lg-4 mb-3" > <design-dropzone-stub - class="design-list-item design-list-item-new" - hasdesigns="true" + class="design-list-item" /> </li> <li - class="col-md-6 col-lg-3 gl-mb-3" + class="col-md-6 col-lg-4 mb-3" > - <design-dropzone-stub - hasdesigns="true" - > + <design-dropzone-stub> <design-stub event="NONE" filename="design-1-name" @@ -177,11 +143,9 @@ exports[`Design management index page designs renders designs list and header wi /> </li> <li - class="col-md-6 col-lg-3 gl-mb-3" + class="col-md-6 col-lg-4 mb-3" > - <design-dropzone-stub - hasdesigns="true" - > + <design-dropzone-stub> <design-stub event="NONE" filename="design-2-name" @@ -197,11 +161,9 @@ exports[`Design management index page designs renders designs list and header wi /> </li> <li - class="col-md-6 col-lg-3 gl-mb-3" + class="col-md-6 col-lg-4 mb-3" > - <design-dropzone-stub - hasdesigns="true" - > + <design-dropzone-stub> <design-stub event="NONE" filename="design-3-name" @@ -226,10 +188,7 @@ exports[`Design management index page designs renders designs list and header wi `; exports[`Design management index page designs renders error 1`] = ` -<div - class="gl-mt-5" - data-testid="designs-root" -> +<div> <!----> <div @@ -257,10 +216,7 @@ exports[`Design management index page designs renders error 1`] = ` `; exports[`Design management index page designs renders loading icon 1`] = ` -<div - class="gl-mt-5" - data-testid="designs-root" -> +<div> <!----> <div @@ -279,11 +235,8 @@ exports[`Design management index page designs renders loading icon 1`] = ` </div> `; -exports[`Design management index page when has no designs renders design dropzone 1`] = ` -<div - class="gl-mt-5" - data-testid="designs-root" -> +exports[`Design management index page when has no designs renders empty text 1`] = ` +<div> <!----> <div @@ -292,18 +245,11 @@ exports[`Design management index page when has no designs renders design dropzon <ol class="list-unstyled row" > - <span - class="gl-font-weight-bold gl-font-weight-bold gl-ml-5 gl-mb-4" - > - Designs - </span> - <li - class="col-12" - data-testid="design-dropzone-wrapper" + class="col-md-6 col-lg-4 mb-3" > <design-dropzone-stub - class="" + class="design-list-item" /> </li> diff --git a/spec/frontend/design_management_new/pages/design/__snapshots__/index_spec.js.snap b/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap index 83bcebd513e..dc5baf37fc6 100644 --- a/spec/frontend/design_management_new/pages/design/__snapshots__/index_spec.js.snap +++ b/spec/frontend/design_management_legacy/pages/design/__snapshots__/index_spec.js.snap @@ -10,7 +10,7 @@ exports[`Design management design index page renders design index 1`] = ` <design-destroyer-stub filenames="test.jpg" iid="1" - project-path="project-path" + projectpath="" /> <!----> @@ -41,7 +41,7 @@ exports[`Design management design index page renders design index 1`] = ` </h2> <a - class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" href="full-issue-url" > ull-issue-path @@ -60,13 +60,13 @@ exports[`Design management design index page renders design index 1`] = ` designid="test" discussion="[object Object]" discussionwithopenform="" - markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + markdownpreviewpath="//preview_markdown?target_type=Issue" noteableid="design-id" /> <gl-button-stub - category="tertiary" - class="link-inherit-color gl-text-black-normal gl-text-decoration-none gl-font-weight-bold gl-mb-4" + 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" @@ -108,7 +108,7 @@ exports[`Design management design index page renders design index 1`] = ` designid="test" discussion="[object Object]" discussionwithopenform="" - markdownpreviewpath="/project-path/preview_markdown?target_type=Issue" + markdownpreviewpath="//preview_markdown?target_type=Issue" noteableid="design-id" /> </gl-collapse-stub> @@ -140,7 +140,7 @@ exports[`Design management design index page with error GlAlert is rendered in c <design-destroyer-stub filenames="test.jpg" iid="1" - project-path="project-path" + projectpath="" /> <div @@ -188,7 +188,7 @@ exports[`Design management design index page with error GlAlert is rendered in c </h2> <a - class="gl-text-gray-600 gl-text-decoration-none gl-mb-6 gl-display-block" + class="gl-text-gray-400 gl-text-decoration-none gl-mb-6 gl-display-block" href="full-issue-url" > ull-issue-path diff --git a/spec/frontend/design_management_new/pages/design/index_spec.js b/spec/frontend/design_management_legacy/pages/design/index_spec.js index 3822b0b3b71..5eb4158c715 100644 --- a/spec/frontend/design_management_new/pages/design/index_spec.js +++ b/spec/frontend/design_management_legacy/pages/design/index_spec.js @@ -2,11 +2,11 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import VueRouter from 'vue-router'; import { GlAlert } from '@gitlab/ui'; import { ApolloMutation } from 'vue-apollo'; -import createFlash from '~/flash'; -import DesignIndex from '~/design_management_new/pages/design/index.vue'; -import DesignSidebar from '~/design_management_new/components/design_sidebar.vue'; -import DesignPresentation from '~/design_management_new/components/design_presentation.vue'; -import createImageDiffNoteMutation from '~/design_management_new/graphql/mutations/create_image_diff_note.mutation.graphql'; +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'; @@ -14,11 +14,11 @@ import mockAllVersions from '../../mock_data/all_versions'; import { DESIGN_NOT_FOUND_ERROR, DESIGN_VERSION_NOT_EXIST_ERROR, -} from '~/design_management_new/utils/error_messages'; -import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants'; -import createRouter from '~/design_management_new/router'; -import * as utils from '~/design_management_new/utils/design_management_utils'; -import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_new/constants'; +} 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', () => ({ @@ -95,12 +95,9 @@ describe('Design management design index page', () => { DesignSidebar, DesignReplyForm, }, - provide: { - issueIid: '1', - projectPath: 'project-path', - }, data() { return { + issueIid: '1', activeDiscussion: { id: null, source: null, @@ -152,7 +149,7 @@ describe('Design management design index page', () => { expect(findSidebar().props()).toEqual({ design, - markdownPreviewPath: '/project-path/preview_markdown?target_type=Issue', + markdownPreviewPath: '//preview_markdown?target_type=Issue', resolvedDiscussionsExpanded: false, }); }); diff --git a/spec/frontend/design_management_new/pages/index_spec.js b/spec/frontend/design_management_legacy/pages/index_spec.js index 40a462eabb8..5b7512aab7b 100644 --- a/spec/frontend/design_management_new/pages/index_spec.js +++ b/spec/frontend/design_management_legacy/pages/index_spec.js @@ -2,20 +2,20 @@ 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_new/pages/index.vue'; -import uploadDesignQuery from '~/design_management_new/graphql/mutations/upload_design.mutation.graphql'; -import DesignDestroyer from '~/design_management_new/components/design_destroyer.vue'; -import DesignDropzone from '~/design_management_new/components/upload/design_dropzone.vue'; -import DeleteButton from '~/design_management_new/components/delete_button.vue'; -import { DESIGNS_ROUTE_NAME } from '~/design_management_new/router/constants'; +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_new/utils/error_messages'; -import createFlash from '~/flash'; -import createRouter from '~/design_management_new/router'; -import * as utils from '~/design_management_new/utils/design_management_utils'; -import { DESIGN_DETAIL_LAYOUT_CLASSLIST } from '~/design_management_new/constants'; +} 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 = { @@ -68,8 +68,6 @@ describe('Design management index page', () => { const findToolbar = () => wrapper.find('.qa-selector-toolbar'); const findDeleteButton = () => wrapper.find(DeleteButton); const findDropzone = () => wrapper.findAll(DesignDropzone).at(0); - const dropzoneClasses = () => findDropzone().classes(); - const findDropzoneWrapper = () => wrapper.find('[data-testid="design-dropzone-wrapper"]'); const findFirstDropzoneWithDesign = () => wrapper.findAll(DesignDropzone).at(1); function createComponent({ @@ -94,23 +92,19 @@ describe('Design management index page', () => { }; wrapper = shallowMount(Index, { - data() { - return { - designs, - allVersions, - permissions: { - createDesign, - }, - }; - }, mocks: { $apollo }, localVue, router, stubs: { DesignDestroyer, ApolloMutation, ...stubs }, attachToDocument: true, - provide: { - projectPath: 'project-path', - issueIid: '1', + }); + + wrapper.setData({ + designs, + allVersions, + issueIid: '1', + permissions: { + createDesign, }, }); } @@ -123,7 +117,9 @@ describe('Design management index page', () => { it('renders loading icon', () => { createComponent({ loading: true }); - expect(wrapper.element).toMatchSnapshot(); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); }); it('renders error', () => { @@ -139,35 +135,25 @@ describe('Design management index page', () => { it('renders a toolbar with buttons when there are designs', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - expect(findToolbar().exists()).toBe(true); + return wrapper.vm.$nextTick().then(() => { + expect(findToolbar().exists()).toBe(true); + }); }); it('renders designs list and header with upload button', () => { createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - expect(wrapper.element).toMatchSnapshot(); + 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 }); - expect(wrapper.element).toMatchSnapshot(); - }); - - it('has correct classes applied to design dropzone', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - expect(dropzoneClasses()).toContain('design-list-item'); - expect(dropzoneClasses()).toContain('design-list-item-new'); - }); - - it('has correct classes applied to dropzone wrapper', () => { - createComponent({ designs: mockDesigns, allVersions: [mockVersion] }); - expect(findDropzoneWrapper().classes()).toEqual([ - 'gl-flex-direction-column', - 'col-md-6', - 'col-lg-3', - 'gl-mb-3', - ]); + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.element).toMatchSnapshot(); + }); }); }); @@ -176,20 +162,11 @@ describe('Design management index page', () => { createComponent(); }); - it('renders design dropzone', () => + it('renders empty text', () => wrapper.vm.$nextTick().then(() => { expect(wrapper.element).toMatchSnapshot(); })); - it('has correct classes applied to design dropzone', () => { - expect(dropzoneClasses()).not.toContain('design-list-item'); - expect(dropzoneClasses()).not.toContain('design-list-item-new'); - }); - - it('has correct classes applied to dropzone wrapper', () => { - expect(findDropzoneWrapper().classes()).toEqual(['col-12']); - }); - it('does not render a toolbar with buttons', () => wrapper.vm.$nextTick().then(() => { expect(findToolbar().exists()).toBe(false); @@ -208,7 +185,7 @@ describe('Design management index page', () => { mutation: uploadDesignQuery, variables: { files: [{ name: 'test' }], - projectPath: 'project-path', + projectPath: '', iid: '1', }, optimisticResponse: { @@ -254,18 +231,12 @@ describe('Design management index page', () => { }, }; - 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(); - }) - .then(() => { - expect(dropzoneClasses()).toContain('design-list-item'); - expect(dropzoneClasses()).toContain('design-list-item-new'); - }); + 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', () => { @@ -413,7 +384,8 @@ describe('Design management index page', () => { it('renders toolbar buttons', () => { expect(findToolbar().exists()).toBe(true); - expect(findToolbar().isVisible()).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', () => { @@ -470,9 +442,9 @@ describe('Design management index page', () => { }); }); - it('on latest version when has no designs toolbar buttons are invisible', () => { + it('on latest version when has no designs does not render toolbar buttons', () => { createComponent({ designs: [], allVersions: [mockVersion] }); - expect(findToolbar().isVisible()).toBe(false); + expect(findToolbar().exists()).toBe(false); }); describe('on non-latest version', () => { @@ -563,7 +535,7 @@ describe('Design management index page', () => { it('ensures fullscreen layout is not applied', () => { createComponent(true); - wrapper.vm.$router.push('/'); + 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_new/router_spec.js b/spec/frontend/design_management_legacy/router_spec.js index 4d63e622724..5f62793a243 100644 --- a/spec/frontend/design_management_new/router_spec.js +++ b/spec/frontend/design_management_legacy/router_spec.js @@ -1,11 +1,15 @@ import { mount, createLocalVue } from '@vue/test-utils'; import { nextTick } from 'vue'; import VueRouter from 'vue-router'; -import App from '~/design_management_new/components/app.vue'; -import Designs from '~/design_management_new/pages/index.vue'; -import DesignDetail from '~/design_management_new/pages/design/index.vue'; -import createRouter from '~/design_management_new/router'; -import { DESIGNS_ROUTE_NAME, DESIGN_ROUTE_NAME } from '~/design_management_new/router/constants'; +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) { @@ -45,7 +49,7 @@ describe('Design management router', () => { window.location.hash = ''; }); - describe.each([['/'], [{ name: DESIGNS_ROUTE_NAME }]])('root route', routeArg => { + describe.each([['/'], [{ name: ROOT_ROUTE_NAME }]])('root route', routeArg => { it('pushes home component', () => { const wrapper = factory(routeArg); @@ -53,6 +57,14 @@ describe('Design management router', () => { }); }); + 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 => { diff --git a/spec/frontend/design_management_new/utils/cache_update_spec.js b/spec/frontend/design_management_legacy/utils/cache_update_spec.js index 611716d5aa7..dce91b5e59b 100644 --- a/spec/frontend/design_management_new/utils/cache_update_spec.js +++ b/spec/frontend/design_management_legacy/utils/cache_update_spec.js @@ -5,15 +5,15 @@ import { updateStoreAfterAddImageDiffNote, updateStoreAfterUploadDesign, updateStoreAfterUpdateImageDiffNote, -} from '~/design_management_new/utils/cache_update'; +} 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_new/utils/error_messages'; +} from '~/design_management_legacy/utils/error_messages'; import design from '../mock_data/design'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash.js'); diff --git a/spec/frontend/design_management_new/utils/design_management_utils_spec.js b/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js index 8bc33e214be..97e85a24a35 100644 --- a/spec/frontend/design_management_new/utils/design_management_utils_spec.js +++ b/spec/frontend/design_management_legacy/utils/design_management_utils_spec.js @@ -6,7 +6,7 @@ import { updateImageDiffNoteOptimisticResponse, isValidDesignFile, extractDesign, -} from '~/design_management_new/utils/design_management_utils'; +} 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'; diff --git a/spec/frontend/design_management_new/utils/error_messages_spec.js b/spec/frontend/design_management_legacy/utils/error_messages_spec.js index eb5dc0fad20..489ac23da4e 100644 --- a/spec/frontend/design_management_new/utils/error_messages_spec.js +++ b/spec/frontend/design_management_legacy/utils/error_messages_spec.js @@ -1,7 +1,7 @@ import { designDeletionError, designUploadSkippedWarning, -} from '~/design_management_new/utils/error_messages'; +} from '~/design_management_legacy/utils/error_messages'; const mockFilenames = n => Array(n) @@ -55,7 +55,7 @@ describe('Error message', () => { '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) => { - test('returns expected warning message', () => { + it('returns expected warning message', () => { expect(designUploadSkippedWarning(uploadedFiles, skippedFiles)).toBe(expected); }); }); diff --git a/spec/frontend/design_management_new/utils/tracking_spec.js b/spec/frontend/design_management_legacy/utils/tracking_spec.js index ac7267642cb..a59cf80c906 100644 --- a/spec/frontend/design_management_new/utils/tracking_spec.js +++ b/spec/frontend/design_management_legacy/utils/tracking_spec.js @@ -1,5 +1,5 @@ import { mockTracking } from 'helpers/tracking_helper'; -import { trackDesignDetailView } from '~/design_management_new/utils/tracking'; +import { trackDesignDetailView } from '~/design_management_legacy/utils/tracking'; function getTrackingSpy(key) { return mockTracking(key, undefined, jest.spyOn); diff --git a/spec/frontend/design_management_new/components/list/__snapshots__/item_spec.js.snap b/spec/frontend/design_management_new/components/list/__snapshots__/item_spec.js.snap deleted file mode 100644 index 8c6e20cb54c..00000000000 --- a/spec/frontend/design_management_new/components/list/__snapshots__/item_spec.js.snap +++ /dev/null @@ -1,472 +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 no notes renders item with correct status icon for creation event 1`] = ` -<router-link-stub - class="card 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" - > - <div - class="design-event position-absolute" - > - <span - aria-label="Added in this version" - title="Added in this version" - > - <icon-stub - class="text-success-500" - name="file-addition-solid" - size="18" - /> - </span> - </div> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders item with correct status icon for deletion event 1`] = ` -<router-link-stub - class="card 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" - > - <div - class="design-event position-absolute" - > - <span - aria-label="Deleted in this version" - title="Deleted in this version" - > - <icon-stub - class="text-danger-500" - name="file-deletion-solid" - size="18" - /> - </span> - </div> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders item with correct status icon for modification event 1`] = ` -<router-link-stub - class="card 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" - > - <div - class="design-event position-absolute" - > - <span - aria-label="Modified in this version" - title="Modified in this version" - > - <icon-stub - class="text-primary-500" - name="file-modified-solid" - size="18" - /> - </span> - </div> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders item with no status icon for none event 1`] = ` -<router-link-stub - class="card 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" - > - <!----> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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> -</router-link-stub> -`; - -exports[`Design management list item component with no notes renders loading spinner when isUploading is true 1`] = ` -<router-link-stub - class="card 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" - > - <!----> - - <gl-intersection-observer-stub - options="[object Object]" - > - <gl-loading-icon-stub - color="orange" - label="Loading" - size="md" - /> - - <img - alt="test" - class="block mx-auto mw-100 mh-100 design-img" - data-qa-selector="design_image" - src="" - style="display: none;" - /> - </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> -</router-link-stub> -`; - -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" - to="[object Object]" -> - <div - class="card-body p-0 d-flex-center overflow-hidden position-relative" - > - <!----> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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 design-list-item-new" - to="[object Object]" -> - <div - class="card-body p-0 d-flex-center overflow-hidden position-relative" - > - <!----> - - <gl-intersection-observer-stub - options="[object Object]" - > - <!----> - - <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_new/components/toolbar/__snapshots__/pagination_button_spec.js.snap b/spec/frontend/design_management_new/components/toolbar/__snapshots__/pagination_button_spec.js.snap deleted file mode 100644 index 08662a04f15..00000000000 --- a/spec/frontend/design_management_new/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/diffs/components/app_spec.js b/spec/frontend/diffs/components/app_spec.js index b7f03f35dfb..ac046ddc203 100644 --- a/spec/frontend/diffs/components/app_spec.js +++ b/spec/frontend/diffs/components/app_spec.js @@ -41,6 +41,7 @@ describe('diffs/components/app', () => { store = createDiffsStore(); store.state.diffs.isLoading = false; + store.state.diffs.isTreeLoaded = true; extendStore(store); diff --git a/spec/frontend/diffs/components/compare_versions_spec.js b/spec/frontend/diffs/components/compare_versions_spec.js index 7f69a6344c1..7fdbc791589 100644 --- a/spec/frontend/diffs/components/compare_versions_spec.js +++ b/spec/frontend/diffs/components/compare_versions_spec.js @@ -30,7 +30,7 @@ describe('CompareVersions', () => { store, propsData: { mergeRequestDiffs: diffsMockData, - diffFilesLength: 0, + diffFilesCountText: null, ...props, }, }); diff --git a/spec/frontend/diffs/components/diff_expansion_cell_spec.js b/spec/frontend/diffs/components/diff_expansion_cell_spec.js index ef2e0dfe59b..b8aca4ad86b 100644 --- a/spec/frontend/diffs/components/diff_expansion_cell_spec.js +++ b/spec/frontend/diffs/components/diff_expansion_cell_spec.js @@ -1,12 +1,12 @@ import Vue from 'vue'; import { cloneDeep } from 'lodash'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { getByText } from '@testing-library/dom'; import { createStore } from '~/mr_notes/stores'; import DiffExpansionCell from '~/diffs/components/diff_expansion_cell.vue'; import { getPreviousLineIndex } from '~/diffs/store/utils'; import { INLINE_DIFF_VIEW_TYPE, PARALLEL_DIFF_VIEW_TYPE } from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; -import { getByText } from '@testing-library/dom'; const EXPAND_UP_CLASS = '.js-unfold'; const EXPAND_DOWN_CLASS = '.js-unfold-down'; diff --git a/spec/frontend/diffs/components/diff_file_spec.js b/spec/frontend/diffs/components/diff_file_spec.js index 7e154d76f45..ead8bd79cdb 100644 --- a/spec/frontend/diffs/components/diff_file_spec.js +++ b/spec/frontend/diffs/components/diff_file_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import { createStore } from '~/mr_notes/stores'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import { createStore } from '~/mr_notes/stores'; import DiffFileComponent from '~/diffs/components/diff_file.vue'; import { diffViewerModes, diffViewerErrors } from '~/ide/constants'; import diffFileMockDataReadable from '../mock_data/diff_file'; @@ -128,6 +128,26 @@ describe('DiffFile', () => { }); }); + 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 = {}; + + done(); + }); + }); + it('should be collapsed for renamed files', done => { vm.renderIt = true; vm.isCollapsed = false; diff --git a/spec/frontend/diffs/components/diff_stats_spec.js b/spec/frontend/diffs/components/diff_stats_spec.js index 5956b478019..7a083fb6bde 100644 --- a/spec/frontend/diffs/components/diff_stats_spec.js +++ b/spec/frontend/diffs/components/diff_stats_spec.js @@ -2,53 +2,97 @@ import { shallowMount } from '@vue/test-utils'; 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; +const DIFF_FILES_COUNT = '300'; +const DIFF_FILES_COUNT_TRUNCATED = '300+'; + describe('diff_stats', () => { - it('does not render a group if diffFileLengths is empty', () => { - const wrapper = shallowMount(DiffStats, { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = shallowMount(DiffStats, { propsData: { - addedLines: 1, - removedLines: 2, + addedLines: TEST_ADDED_LINES, + removedLines: TEST_REMOVED_LINES, + ...props, }, }); - const groups = wrapper.findAll('.diff-stats-group'); + }; - expect(groups.length).toBe(2); - }); + describe('diff stats group', () => { + const findDiffStatsGroup = () => wrapper.findAll('.diff-stats-group'); - it('does not render a group if diffFileLengths is not a number', () => { - const wrapper = shallowMount(DiffStats, { - propsData: { - addedLines: 1, - removedLines: 2, - diffFilesLength: Number.NaN, - }, + it('is not rendered if diffFilesCountText is empty', () => { + createComponent(); + + expect(findDiffStatsGroup().length).toBe(2); }); - const groups = wrapper.findAll('.diff-stats-group'); - expect(groups.length).toBe(2); - }); + it('is not rendered if diffFilesCountText is not a number', () => { + createComponent({ + diffFilesCountText: null, + }); - it('shows amount of files changed, lines added and lines removed when passed all props', () => { - const wrapper = shallowMount(DiffStats, { - propsData: { - addedLines: 100, - removedLines: 200, - diffFilesLength: 300, - }, + expect(findDiffStatsGroup().length).toBe(2); }); + }); + describe('line changes', () => { const findFileLine = name => wrapper.find(name); + + it('shows the amount of lines added', () => { + expect(findFileLine('.js-file-addition-line').text()).toBe(TEST_ADDED_LINES.toString()); + }); + + it('shows the amount of lines removed', () => { + expect(findFileLine('.js-file-deletion-line').text()).toBe(TEST_REMOVED_LINES.toString()); + }); + }); + + describe('files changes', () => { const findIcon = name => wrapper .findAll(Icon) .filter(c => c.attributes('name') === name) .at(0).element.parentNode; - const additions = findFileLine('.js-file-addition-line'); - const deletions = findFileLine('.js-file-deletion-line'); - const filesChanged = findIcon('doc-code'); - expect(additions.text()).toBe('100'); - expect(deletions.text()).toBe('200'); - expect(filesChanged.textContent).toContain('300'); + it('shows amount of file changed with plural "files" when 0 files has changed', () => { + const oneFileChanged = '0'; + + createComponent({ + diffFilesCountText: oneFileChanged, + }); + + expect(findIcon('doc-code').textContent.trim()).toBe(`${oneFileChanged} files`); + }); + + it('shows amount of file changed with singular "file" when 1 file is changed', () => { + const oneFileChanged = '1'; + + createComponent({ + diffFilesCountText: oneFileChanged, + }); + + expect(findIcon('doc-code').textContent.trim()).toBe(`${oneFileChanged} file`); + }); + + it('shows amount of files change with plural "files" when multiple files are changed', () => { + createComponent({ + diffFilesCountText: DIFF_FILES_COUNT, + }); + + expect(findIcon('doc-code').textContent.trim()).toContain(`${DIFF_FILES_COUNT} files`); + }); + + it('shows amount of files change with plural "files" when files changed is truncated', () => { + createComponent({ + diffFilesCountText: DIFF_FILES_COUNT_TRUNCATED, + }); + + expect(findIcon('doc-code').textContent.trim()).toContain( + `${DIFF_FILES_COUNT_TRUNCATED} files`, + ); + }); }); }); diff --git a/spec/frontend/diffs/components/diff_table_cell_spec.js b/spec/frontend/diffs/components/diff_table_cell_spec.js index 9693fe68b57..02f5c27eecb 100644 --- a/spec/frontend/diffs/components/diff_table_cell_spec.js +++ b/spec/frontend/diffs/components/diff_table_cell_spec.js @@ -1,10 +1,10 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import { TEST_HOST } from 'helpers/test_constants'; import DiffTableCell from '~/diffs/components/diff_table_cell.vue'; import DiffGutterAvatars from '~/diffs/components/diff_gutter_avatars.vue'; import { LINE_POSITION_RIGHT } from '~/diffs/constants'; import { createStore } from '~/mr_notes/stores'; -import { TEST_HOST } from 'helpers/test_constants'; import discussionsMockData from '../mock_data/diff_discussions'; import diffFileMockData from '../mock_data/diff_file'; @@ -18,6 +18,12 @@ const TEST_LINE_CODE = 'LC_42'; const TEST_FILE_HASH = diffFileMockData.file_hash; describe('DiffTableCell', () => { + 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'; + let wrapper; let line; let store; @@ -67,6 +73,7 @@ describe('DiffTableCell', () => { const findTd = () => wrapper.find({ ref: 'td' }); const findNoteButton = () => wrapper.find({ ref: 'addDiffNoteButton' }); const findLineNumber = () => wrapper.find({ ref: 'lineNumberRef' }); + const findTooltip = () => wrapper.find({ ref: 'addNoteTooltip' }); const findAvatars = () => wrapper.find(DiffGutterAvatars); describe('td', () => { @@ -134,6 +141,53 @@ describe('DiffTableCell', () => { }); }, ); + + it.each` + disabled | commentsDisabled + ${'disabled'} | ${true} + ${undefined} | ${false} + `( + 'has attribute disabled=$disabled when the outer component has prop commentsDisabled=$commentsDisabled', + ({ disabled, commentsDisabled }) => { + line.commentsDisabled = commentsDisabled; + + createComponent({ + showCommentButton: true, + isHover: true, + }); + + wrapper.setData({ isCommentButtonRendered: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(findNoteButton().attributes('disabled')).toBe(disabled); + }); + }, + ); + + 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 }) => { + line.commentsDisabled = commentsDisabled; + + createComponent({ + showCommentButton: true, + isHover: true, + }); + + wrapper.setData({ isCommentButtonRendered: true }); + + return wrapper.vm.$nextTick().then(() => { + expect(findTooltip().attributes('title')).toBe(tooltip); + }); + }, + ); }); describe('line number', () => { diff --git a/spec/frontend/diffs/components/inline_diff_view_spec.js b/spec/frontend/diffs/components/inline_diff_view_spec.js index eeef8e5a7b0..6c37f86658e 100644 --- a/spec/frontend/diffs/components/inline_diff_view_spec.js +++ b/spec/frontend/diffs/components/inline_diff_view_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import '~/behaviors/markdown/render_gfm'; -import { createStore } from '~/mr_notes/stores'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/mr_notes/stores'; import InlineDiffView from '~/diffs/components/inline_diff_view.vue'; import diffFileMockData from '../mock_data/diff_file'; import discussionsMockData from '../mock_data/diff_discussions'; diff --git a/spec/frontend/diffs/components/no_changes_spec.js b/spec/frontend/diffs/components/no_changes_spec.js index 2eca97a47fd..2795c68b4ee 100644 --- a/spec/frontend/diffs/components/no_changes_spec.js +++ b/spec/frontend/diffs/components/no_changes_spec.js @@ -1,8 +1,8 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; +import { GlButton } from '@gitlab/ui'; import { createStore } from '~/mr_notes/stores'; import NoChanges from '~/diffs/components/no_changes.vue'; -import { GlButton } from '@gitlab/ui'; describe('Diff no changes empty state', () => { let vm; diff --git a/spec/frontend/diffs/components/parallel_diff_view_spec.js b/spec/frontend/diffs/components/parallel_diff_view_spec.js index 30231f0ba71..cb1a47f60d5 100644 --- a/spec/frontend/diffs/components/parallel_diff_view_spec.js +++ b/spec/frontend/diffs/components/parallel_diff_view_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import { createStore } from '~/mr_notes/stores'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { createStore } from '~/mr_notes/stores'; import ParallelDiffView from '~/diffs/components/parallel_diff_view.vue'; import * as constants from '~/diffs/constants'; import diffFileMockData from '../mock_data/diff_file'; diff --git a/spec/frontend/diffs/components/tree_list_spec.js b/spec/frontend/diffs/components/tree_list_spec.js index f78c5f25ee7..14cb2a17aec 100644 --- a/spec/frontend/diffs/components/tree_list_spec.js +++ b/spec/frontend/diffs/components/tree_list_spec.js @@ -17,6 +17,7 @@ describe('Diffs tree list component', () => { }); // Setup initial state + store.state.diffs.isTreeLoaded = true; store.state.diffs.diffFiles.push('test'); store.state.diffs = { addedLines: 10, diff --git a/spec/frontend/diffs/diff_file_spec.js b/spec/frontend/diffs/diff_file_spec.js new file mode 100644 index 00000000000..5d74760ef66 --- /dev/null +++ b/spec/frontend/diffs/diff_file_spec.js @@ -0,0 +1,60 @@ +import { prepareRawDiffFile } from '~/diffs/diff_file'; + +const DIFF_FILES = [ + { + file_hash: 'ABC', // This file is just a normal file + }, + { + file_hash: 'DEF', // This file replaces a symlink + a_mode: '0', + b_mode: '0755', + }, + { + file_hash: 'DEF', // This symlink is replaced by a file + a_mode: '120000', + b_mode: '0', + }, + { + file_hash: 'GHI', // This symlink replaces a file + a_mode: '0', + b_mode: '120000', + }, + { + file_hash: 'GHI', // This file is replaced by a symlink + a_mode: '0755', + b_mode: '0', + }, +]; + +function makeBrokenSymlinkObject(replaced, wasSymbolic, isSymbolic, wasReal, isReal) { + return { + replaced, + wasSymbolic, + isSymbolic, + wasReal, + isReal, + }; +} + +describe('diff_file utilities', () => { + describe('prepareRawDiffFile', () => { + it.each` + fileIndex | description | brokenSymlink + ${0} | ${'a file that is not symlink-adjacent'} | ${false} + ${1} | ${'a file that replaces a symlink'} | ${makeBrokenSymlinkObject(false, false, false, false, true)} + ${2} | ${'a symlink that is replaced by a file'} | ${makeBrokenSymlinkObject(true, true, false, false, false)} + ${3} | ${'a symlink that replaces a file'} | ${makeBrokenSymlinkObject(false, false, true, false, false)} + ${4} | ${'a file that is replaced by a symlink'} | ${makeBrokenSymlinkObject(true, false, false, true, false)} + `( + 'properly marks $description with the correct .brokenSymlink value', + ({ fileIndex, brokenSymlink }) => { + const preppedRaw = prepareRawDiffFile({ + file: DIFF_FILES[fileIndex], + allFiles: DIFF_FILES, + }); + + expect(preppedRaw.brokenSymlink).toStrictEqual(brokenSymlink); + }, + ); + }); +}); diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index fc5e39357ca..5fef35d6c5b 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -1,6 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; import Cookies from 'js-cookie'; import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import { DIFF_VIEW_COOKIE_NAME, INLINE_DIFF_VIEW_TYPE, @@ -56,12 +58,10 @@ import testAction from '../../helpers/vuex_action_helper'; import * as utils from '~/diffs/store/utils'; import * as commonUtils from '~/lib/utils/common_utils'; import { mergeUrlParams } from '~/lib/utils/url_utility'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { diffMetadata } from '../mock_data/diff_metadata'; -import createFlash from '~/flash'; -import { TEST_HOST } from 'jest/helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; -jest.mock('~/flash', () => jest.fn()); +jest.mock('~/flash'); describe('DiffsStoreActions', () => { useLocalStorageSpy(); @@ -1594,24 +1594,39 @@ describe('DiffsStoreActions', () => { describe('setCurrentDiffFileIdFromNote', () => { it('commits UPDATE_CURRENT_DIFF_FILE_ID', () => { const commit = jest.fn(); + const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { getDiscussion: () => ({ diff_file: { file_hash: '123' } }), notesById: { '1': { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, rootGetters }, '1'); + setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).toHaveBeenCalledWith(types.UPDATE_CURRENT_DIFF_FILE_ID, '123'); }); it('does not commit UPDATE_CURRENT_DIFF_FILE_ID when discussion has no diff_file', () => { const commit = jest.fn(); + const state = { diffFiles: [{ file_hash: '123' }] }; const rootGetters = { getDiscussion: () => ({ id: '1' }), notesById: { '1': { discussion_id: '2' } }, }; - setCurrentDiffFileIdFromNote({ commit, rootGetters }, '1'); + setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); + + expect(commit).not.toHaveBeenCalled(); + }); + + it('does not commit UPDATE_CURRENT_DIFF_FILE_ID when diff file does not exist', () => { + const commit = jest.fn(); + const state = { diffFiles: [{ file_hash: '123' }] }; + const rootGetters = { + getDiscussion: () => ({ diff_file: { file_hash: '124' } }), + notesById: { '1': { discussion_id: '2' } }, + }; + + setCurrentDiffFileIdFromNote({ commit, state, rootGetters }, '1'); expect(commit).not.toHaveBeenCalled(); }); diff --git a/spec/frontend/diffs/store/mutations_spec.js b/spec/frontend/diffs/store/mutations_spec.js index c24d406fef3..70047899612 100644 --- a/spec/frontend/diffs/store/mutations_spec.js +++ b/spec/frontend/diffs/store/mutations_spec.js @@ -830,6 +830,7 @@ describe('DiffsStoreMutations', () => { const state = { treeEntries: {}, tree: [], + isTreeLoaded: false, }; mutations[types.SET_TREE_DATA](state, { @@ -844,6 +845,7 @@ describe('DiffsStoreMutations', () => { }); expect(state.tree).toEqual(['tree']); + expect(state.isTreeLoaded).toEqual(true); }); }); diff --git a/spec/frontend/diffs/store/utils_spec.js b/spec/frontend/diffs/store/utils_spec.js index d87619e1e3c..62c82468ea0 100644 --- a/spec/frontend/diffs/store/utils_spec.js +++ b/spec/frontend/diffs/store/utils_spec.js @@ -20,6 +20,14 @@ import { noteableDataMock } from '../../notes/mock_data'; const getDiffFileMock = () => JSON.parse(JSON.stringify(diffFileMockData)); const getDiffMetadataMock = () => JSON.parse(JSON.stringify(diffMetadata)); +function extractLinesFromFile(file) { + const unpackedParallel = file.parallel_diff_lines + .flatMap(({ left, right }) => [left, right]) + .filter(Boolean); + + return [...file.highlighted_diff_lines, ...unpackedParallel]; +} + describe('DiffsStoreUtils', () => { describe('findDiffFile', () => { const files = [{ file_hash: 1, name: 'one' }]; @@ -429,6 +437,28 @@ describe('DiffsStoreUtils', () => { expect(preppedLine.right).toEqual(correctLine); expect(preppedLine.line_code).toEqual(correctLine.line_code); }); + + it.each` + brokenSymlink + ${false} + ${{}} + ${'anything except `false`'} + `( + "properly assigns each line's `commentsDisabled` as the same value as the parent file's `brokenSymlink` value (`$brokenSymlink`)", + ({ brokenSymlink }) => { + preppedLine = utils.prepareLineForRenamedFile({ + diffViewType: INLINE_DIFF_VIEW_TYPE, + line: sourceLine, + index: lineIndex, + diffFile: { + ...diffFile, + brokenSymlink, + }, + }); + + expect(preppedLine.commentsDisabled).toStrictEqual(brokenSymlink); + }, + ); }); describe('prepareDiffData', () => { @@ -541,6 +571,25 @@ describe('DiffsStoreUtils', () => { }), ]); }); + + it('adds the `.brokenSymlink` property to each diff file', () => { + preparedDiff.diff_files.forEach(file => { + expect(file).toEqual(expect.objectContaining({ brokenSymlink: false })); + }); + }); + + it("copies the diff file's `.brokenSymlink` value to each of that file's child lines", () => { + const lines = [ + ...preparedDiff.diff_files, + ...splitInlineDiff.diff_files, + ...splitParallelDiff.diff_files, + ...completedDiff.diff_files, + ].flatMap(file => extractLinesFromFile(file)); + + lines.forEach(line => { + expect(line.commentsDisabled).toBe(false); + }); + }); }); describe('for diff metadata', () => { @@ -603,6 +652,12 @@ describe('DiffsStoreUtils', () => { }, ]); }); + + it('adds the `.brokenSymlink` property to each meta diff file', () => { + preparedDiffFiles.forEach(file => { + expect(file).toMatchObject({ brokenSymlink: false }); + }); + }); }); }); diff --git a/spec/frontend/dropzone_input_spec.js b/spec/frontend/dropzone_input_spec.js index 688b9164e5f..4cfc6478bd2 100644 --- a/spec/frontend/dropzone_input_spec.js +++ b/spec/frontend/dropzone_input_spec.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import mock from 'xhr-mock'; import { TEST_HOST } from 'spec/test_constants'; +import waitForPromises from 'helpers/wait_for_promises'; import dropzoneInput from '~/dropzone_input'; import PasteMarkdownTable from '~/behaviors/markdown/paste_markdown_table'; -import waitForPromises from 'helpers/wait_for_promises'; const TEST_FILE = new File([], 'somefile.jpg'); TEST_FILE.upload = {}; diff --git a/spec/frontend/editor/editor_lite_spec.js b/spec/frontend/editor/editor_lite_spec.js index 92a136835bf..e4edeab172b 100644 --- a/spec/frontend/editor/editor_lite_spec.js +++ b/spec/frontend/editor/editor_lite_spec.js @@ -2,13 +2,15 @@ import { editor as monacoEditor, languages as monacoLanguages, Uri } from 'monac import Editor from '~/editor/editor_lite'; import { DEFAULT_THEME, themes } from '~/ide/lib/themes'; +const URI_PREFIX = 'gitlab'; + describe('Base editor', () => { let editorEl; let editor; const blobContent = 'Foo Bar'; const blobPath = 'test.md'; - const uri = new Uri('gitlab', false, blobPath); - const fakeModel = { foo: 'bar' }; + const blobGlobalId = 'snippet_777'; + const fakeModel = { foo: 'bar', dispose: jest.fn() }; beforeEach(() => { setFixtures('<div id="editor" data-editor-loading></div>'); @@ -21,6 +23,8 @@ describe('Base editor', () => { editorEl.remove(); }); + const createUri = (...paths) => Uri.file([URI_PREFIX, ...paths].join('/')); + it('initializes Editor with basic properties', () => { expect(editor).toBeDefined(); expect(editor.editorEl).toBe(null); @@ -65,7 +69,7 @@ describe('Base editor', () => { it('creates model to be supplied to Monaco editor', () => { editor.createInstance({ el: editorEl, blobPath, blobContent }); - expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, uri); + expect(modelSpy).toHaveBeenCalledWith(blobContent, undefined, createUri(blobPath)); expect(setModel).toHaveBeenCalledWith(fakeModel); }); @@ -75,6 +79,16 @@ describe('Base editor', () => { expect(editor.editorEl).not.toBe(null); expect(instanceSpy).toHaveBeenCalledWith(editorEl, expect.anything()); }); + + it('with blobGlobalId, creates model with id in uri', () => { + editor.createInstance({ el: editorEl, blobPath, blobContent, blobGlobalId }); + + expect(modelSpy).toHaveBeenCalledWith( + blobContent, + undefined, + createUri(blobGlobalId, blobPath), + ); + }); }); describe('implementation', () => { @@ -82,10 +96,6 @@ describe('Base editor', () => { editor.createInstance({ el: editorEl, blobPath, blobContent }); }); - afterEach(() => { - editor.model.dispose(); - }); - it('correctly proxies value from the model', () => { expect(editor.getValue()).toEqual(blobContent); }); @@ -132,10 +142,6 @@ describe('Base editor', () => { editor.createInstance({ el: editorEl, blobPath, blobContent }); }); - afterEach(() => { - editor.model.dispose(); - }); - it('is extensible with the extensions', () => { expect(editor.foo).toBeUndefined(); diff --git a/spec/frontend/editor/editor_markdown_ext_spec.js b/spec/frontend/editor/editor_markdown_ext_spec.js index aad2400c0f0..b0fabad8542 100644 --- a/spec/frontend/editor/editor_markdown_ext_spec.js +++ b/spec/frontend/editor/editor_markdown_ext_spec.js @@ -1,5 +1,5 @@ -import EditorLite from '~/editor/editor_lite'; import { Range, Position } from 'monaco-editor'; +import EditorLite from '~/editor/editor_lite'; import EditorMarkdownExtension from '~/editor/editor_markdown_ext'; describe('Markdown Extension for Editor Lite', () => { diff --git a/spec/frontend/emoji/emoji_spec.js b/spec/frontend/emoji/emoji_spec.js index c6a15d5976a..9b49c8b8ab5 100644 --- a/spec/frontend/emoji/emoji_spec.js +++ b/spec/frontend/emoji/emoji_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { trimText } from 'helpers/text_helper'; import axios from '~/lib/utils/axios_utils'; import { initEmojiMap, glEmojiTag, EMOJI_VERSION } from '~/emoji'; import isEmojiUnicodeSupported, { @@ -9,7 +10,6 @@ import isEmojiUnicodeSupported, { isHorceRacingSkinToneComboEmoji, isPersonZwjEmoji, } from '~/emoji/support/is_emoji_unicode_supported'; -import { trimText } from 'helpers/text_helper'; const emptySupportMap = { personZwj: false, diff --git a/spec/frontend/emoji/support/unicode_support_map_spec.js b/spec/frontend/emoji/support/unicode_support_map_spec.js index aaee9c30cac..945e804a9fa 100644 --- a/spec/frontend/emoji/support/unicode_support_map_spec.js +++ b/spec/frontend/emoji/support/unicode_support_map_spec.js @@ -1,6 +1,6 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import getUnicodeSupportMap from '~/emoji/support/unicode_support_map'; import AccessorUtilities from '~/lib/utils/accessor'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; describe('Unicode Support Map', () => { useLocalStorageSpy(); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index c9d77a34595..35ca323f5a9 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -50,18 +50,14 @@ class CustomEnvironment extends JSDOMEnvironment { */ this.global.fetch = () => {}; - // Not yet supported by JSDOM: https://github.com/jsdom/jsdom/issues/317 - this.global.document.createRange = () => ({ - setStart: () => {}, - setEnd: () => {}, - commonAncestorContainer: { - nodeName: 'BODY', - ownerDocument: this.global.document, - }, - }); - // Expose the jsdom (created in super class) to the global so that we can call reconfigure({ url: '' }) to properly set `window.location` - this.global.dom = this.dom; + this.global.jsdom = this.dom; + + Object.assign(this.global.performance, { + mark: () => null, + measure: () => null, + getEntriesByName: () => [], + }); } async teardown() { diff --git a/spec/frontend/environments/environment_actions_spec.js b/spec/frontend/environments/environment_actions_spec.js index 4c06e19cec0..e7f5ee4bc4d 100644 --- a/spec/frontend/environments/environment_actions_spec.js +++ b/spec/frontend/environments/environment_actions_spec.js @@ -1,9 +1,9 @@ import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; +import { GlLoadingIcon } 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'; -import { GlLoadingIcon } from '@gitlab/ui'; describe('EnvironmentActions Component', () => { let vm; diff --git a/spec/frontend/environments/environment_external_url_spec.js b/spec/frontend/environments/environment_external_url_spec.js index 9997ea94941..4c133665979 100644 --- a/spec/frontend/environments/environment_external_url_spec.js +++ b/spec/frontend/environments/environment_external_url_spec.js @@ -1,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import ExternalUrlComp from '~/environments/components/environment_external_url.vue'; describe('External URL Component', () => { @@ -6,7 +6,7 @@ describe('External URL Component', () => { const externalUrl = 'https://gitlab.com'; beforeEach(() => { - wrapper = shallowMount(ExternalUrlComp, { propsData: { externalUrl } }); + wrapper = mount(ExternalUrlComp, { propsData: { externalUrl } }); }); it('should link to the provided externalUrl prop', () => { diff --git a/spec/frontend/environments/environment_stop_spec.js b/spec/frontend/environments/environment_stop_spec.js index f971cf56b65..1865403cdc4 100644 --- a/spec/frontend/environments/environment_stop_spec.js +++ b/spec/frontend/environments/environment_stop_spec.js @@ -1,7 +1,7 @@ import $ from 'jquery'; import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import StopComponent from '~/environments/components/environment_stop.vue'; -import LoadingButton from '~/vue_shared/components/loading_button.vue'; import eventHub from '~/environments/event_hub'; $.fn.tooltip = () => {}; @@ -17,7 +17,7 @@ describe('Stop Component', () => { }); }; - const findButton = () => wrapper.find(LoadingButton); + const findButton = () => wrapper.find(GlButton); beforeEach(() => { jest.spyOn(window, 'confirm'); diff --git a/spec/frontend/environments/environments_app_spec.js b/spec/frontend/environments/environments_app_spec.js index c0bf0dca176..d440bf73e15 100644 --- a/spec/frontend/environments/environments_app_spec.js +++ b/spec/frontend/environments/environments_app_spec.js @@ -1,6 +1,6 @@ import { mount, shallowMount } from '@vue/test-utils'; -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import Container from '~/environments/components/container.vue'; import EmptyState from '~/environments/components/empty_state.vue'; import EnvironmentsApp from '~/environments/components/environments_app.vue'; diff --git a/spec/frontend/environments/folder/environments_folder_view_spec.js b/spec/frontend/environments/folder/environments_folder_view_spec.js index 740225ddd9d..f33c8de0094 100644 --- a/spec/frontend/environments/folder/environments_folder_view_spec.js +++ b/spec/frontend/environments/folder/environments_folder_view_spec.js @@ -1,11 +1,11 @@ import { mount } from '@vue/test-utils'; -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import { removeBreakLine, removeWhitespace } from 'helpers/text_helper'; +import { GlPagination } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; import EnvironmentsFolderViewComponent from '~/environments/folder/environments_folder_view.vue'; import EnvironmentTable from '~/environments/components/environments_table.vue'; import { environmentsList } from '../mock_data'; -import { removeBreakLine, removeWhitespace } from 'helpers/text_helper'; -import { GlPagination } from '@gitlab/ui'; describe('Environments Folder View', () => { let mock; diff --git a/spec/frontend/error_tracking/components/error_details_spec.js b/spec/frontend/error_tracking/components/error_details_spec.js index 6124602e038..ef3eeb8c7e4 100644 --- a/spec/frontend/error_tracking/components/error_details_spec.js +++ b/spec/frontend/error_tracking/components/error_details_spec.js @@ -1,7 +1,5 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { __ } from '~/locale'; -import createFlash from '~/flash'; import { GlButton, GlLoadingIcon, @@ -11,6 +9,8 @@ import { GlAlert, GlSprintf, } from '@gitlab/ui'; +import { __ } from '~/locale'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import Stacktrace from '~/error_tracking/components/stacktrace.vue'; import ErrorDetails from '~/error_tracking/components/error_details.vue'; import { diff --git a/spec/frontend/error_tracking/components/error_tracking_actions_spec.js b/spec/frontend/error_tracking/components/error_tracking_actions_spec.js index 1ea92883e54..b22805f5227 100644 --- a/spec/frontend/error_tracking/components/error_tracking_actions_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_actions_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue'; describe('Error Tracking Actions', () => { @@ -20,7 +20,7 @@ describe('Error Tracking Actions', () => { }, ...props, }, - stubs: { GlDeprecatedButton }, + stubs: { GlButton }, }); } @@ -34,7 +34,7 @@ describe('Error Tracking Actions', () => { } }); - const findButtons = () => wrapper.findAll(GlDeprecatedButton); + const findButtons = () => wrapper.findAll(GlButton); describe('when error status is unresolved', () => { it('renders the correct actions buttons to allow ignore and resolve', () => { 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 d88a412fb50..bad70a31599 100644 --- a/spec/frontend/error_tracking/components/error_tracking_list_spec.js +++ b/spec/frontend/error_tracking/components/error_tracking_list_spec.js @@ -1,6 +1,12 @@ import { createLocalVue, mount } from '@vue/test-utils'; import Vuex from 'vuex'; -import { GlEmptyState, GlLoadingIcon, GlFormInput, GlPagination, GlDropdown } from '@gitlab/ui'; +import { + GlEmptyState, + GlLoadingIcon, + GlFormInput, + GlPagination, + GlDeprecatedDropdown, +} from '@gitlab/ui'; import stubChildren from 'helpers/stub_children'; import ErrorTrackingList from '~/error_tracking/components/error_tracking_list.vue'; import ErrorTrackingActions from '~/error_tracking/components/error_tracking_actions.vue'; @@ -18,19 +24,19 @@ describe('ErrorTrackingList', () => { const findErrorListTable = () => wrapper.find('table'); const findErrorListRows = () => wrapper.findAll('tbody tr'); - const dropdownsArray = () => wrapper.findAll(GlDropdown); + const dropdownsArray = () => wrapper.findAll(GlDeprecatedDropdown); const findRecentSearchesDropdown = () => dropdownsArray() .at(0) - .find(GlDropdown); + .find(GlDeprecatedDropdown); const findStatusFilterDropdown = () => dropdownsArray() .at(1) - .find(GlDropdown); + .find(GlDeprecatedDropdown); const findSortDropdown = () => dropdownsArray() .at(2) - .find(GlDropdown); + .find(GlDeprecatedDropdown); const findLoadingIcon = () => wrapper.find(GlLoadingIcon); const findPagination = () => wrapper.find(GlPagination); const findErrorActions = () => wrapper.find(ErrorTrackingActions); @@ -128,8 +134,8 @@ describe('ErrorTrackingList', () => { mountComponent({ stubs: { GlTable: false, - GlDropdown: false, - GlDropdownItem: false, + GlDeprecatedDropdown: false, + GlDeprecatedDropdownItem: false, GlLink: false, }, }); @@ -199,8 +205,8 @@ describe('ErrorTrackingList', () => { mountComponent({ stubs: { GlTable: false, - GlDropdown: false, - GlDropdownItem: false, + GlDeprecatedDropdown: false, + GlDeprecatedDropdownItem: false, }, }); }); @@ -335,8 +341,8 @@ describe('ErrorTrackingList', () => { beforeEach(() => { mountComponent({ stubs: { - GlDropdown: false, - GlDropdownItem: false, + GlDeprecatedDropdown: false, + GlDeprecatedDropdownItem: false, }, }); }); diff --git a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js index de746b8ac84..df7bff201f1 100644 --- a/spec/frontend/error_tracking/components/stacktrace_entry_spec.js +++ b/spec/frontend/error_tracking/components/stacktrace_entry_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { GlSprintf } 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'; -import { trimText } from 'helpers/text_helper'; describe('Stacktrace Entry', () => { let wrapper; diff --git a/spec/frontend/error_tracking/store/actions_spec.js b/spec/frontend/error_tracking/store/actions_spec.js index e4a895902b3..43037473a61 100644 --- a/spec/frontend/error_tracking/store/actions_spec.js +++ b/spec/frontend/error_tracking/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import * as actions from '~/error_tracking/store/actions'; import * as types from '~/error_tracking/store/mutation_types'; import { visitUrl } from '~/lib/utils/url_utility'; diff --git a/spec/frontend/error_tracking/store/details/actions_spec.js b/spec/frontend/error_tracking/store/details/actions_spec.js index 6802300b0f5..58e77c46e02 100644 --- a/spec/frontend/error_tracking/store/details/actions_spec.js +++ b/spec/frontend/error_tracking/store/details/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import * as actions from '~/error_tracking/store/details/actions'; import * as types from '~/error_tracking/store/details/mutation_types'; import Poll from '~/lib/utils/poll'; diff --git a/spec/frontend/error_tracking/store/list/actions_spec.js b/spec/frontend/error_tracking/store/list/actions_spec.js index 3cb740bf05d..7326472e1dd 100644 --- a/spec/frontend/error_tracking/store/list/actions_spec.js +++ b/spec/frontend/error_tracking/store/list/actions_spec.js @@ -1,8 +1,8 @@ -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import * as actions from '~/error_tracking/store/list/actions'; import * as types from '~/error_tracking/store/list/mutation_types'; diff --git a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js index d924f895da8..023a3e26781 100644 --- a/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js +++ b/spec/frontend/error_tracking_settings/components/project_dropdown_spec.js @@ -1,7 +1,7 @@ import { pick, clone } from 'lodash'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import ProjectDropdown from '~/error_tracking_settings/components/project_dropdown.vue'; import { defaultProps, projectList, staleProject } from '../mock'; @@ -43,7 +43,7 @@ describe('error tracking settings project dropdown', () => { describe('empty project list', () => { it('renders the dropdown', () => { expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); - expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); + expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeTruthy(); }); it('shows helper text', () => { @@ -58,8 +58,8 @@ describe('error tracking settings project dropdown', () => { }); it('does not contain any dropdown items', () => { - expect(wrapper.find(GlDropdownItem).exists()).toBeFalsy(); - expect(wrapper.find(GlDropdown).props('text')).toBe('No projects available'); + expect(wrapper.find(GlDeprecatedDropdownItem).exists()).toBeFalsy(); + expect(wrapper.find(GlDeprecatedDropdown).props('text')).toBe('No projects available'); }); }); @@ -72,12 +72,12 @@ describe('error tracking settings project dropdown', () => { it('renders the dropdown', () => { expect(wrapper.find('#project-dropdown').exists()).toBeTruthy(); - expect(wrapper.find(GlDropdown).exists()).toBeTruthy(); + expect(wrapper.find(GlDeprecatedDropdown).exists()).toBeTruthy(); }); it('contains a number of dropdown items', () => { - expect(wrapper.find(GlDropdownItem).exists()).toBeTruthy(); - expect(wrapper.findAll(GlDropdownItem).length).toBe(2); + expect(wrapper.find(GlDeprecatedDropdownItem).exists()).toBeTruthy(); + expect(wrapper.findAll(GlDeprecatedDropdownItem).length).toBe(2); }); }); diff --git a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js index c0851096d8e..158f70f7d47 100644 --- a/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js +++ b/spec/frontend/filtered_search/components/recent_searches_dropdown_content_spec.js @@ -57,7 +57,11 @@ describe('Recent Searches Dropdown Content', () => { beforeEach(() => { createComponent({ - items: ['foo', 'author:@root label:~foo bar'], + items: [ + 'foo', + 'author:@root label:~foo bar', + [{ type: 'author_username', value: { data: 'toby', operator: '=' } }], + ], isLocalStorageAvailable: true, }); }); @@ -76,7 +80,7 @@ describe('Recent Searches Dropdown Content', () => { }); it('renders a correct amount of dropdown items', () => { - expect(findDropdownItems()).toHaveLength(2); + expect(findDropdownItems()).toHaveLength(2); // Ignore non-string recent item }); it('expect second dropdown to have 2 tokens', () => { diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 70e8b339d4b..53c726a6cea 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -77,7 +77,7 @@ describe('Filtered Search Manager', () => { jest.spyOn(FilteredSearchDropdownManager.prototype, 'setDropdown').mockImplementation(); }); - const initializeManager = () => { + const initializeManager = ({ useDefaultState } = {}) => { jest.spyOn(FilteredSearchManager.prototype, 'loadSearchParamsFromURL').mockImplementation(); jest.spyOn(FilteredSearchManager.prototype, 'tokenChange').mockImplementation(); jest @@ -88,7 +88,7 @@ describe('Filtered Search Manager', () => { input = document.querySelector('.filtered-search'); tokensContainer = document.querySelector('.tokens-container'); - manager = new FilteredSearchManager({ page }); + manager = new FilteredSearchManager({ page, useDefaultState }); manager.setup(); }; @@ -184,17 +184,27 @@ describe('Filtered Search Manager', () => { }); describe('search', () => { - const defaultParams = '?scope=all&utf8=%E2%9C%93&state=opened'; + const defaultParams = '?scope=all&utf8=%E2%9C%93'; + const defaultState = '&state=opened'; - beforeEach(() => { + it('should search with a single word', done => { initializeManager(); + input.value = 'searchTerm'; + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&search=searchTerm`); + done(); + }); + + manager.search(); }); - it('should search with a single word', done => { + it('sets default state', done => { + initializeManager({ useDefaultState: true }); input.value = 'searchTerm'; visitUrl.mockImplementation(url => { - expect(url).toEqual(`${defaultParams}&search=searchTerm`); + expect(url).toEqual(`${defaultParams}${defaultState}&search=searchTerm`); done(); }); @@ -202,6 +212,7 @@ describe('Filtered Search Manager', () => { }); it('should search with multiple words', done => { + initializeManager(); input.value = 'awesome search terms'; visitUrl.mockImplementation(url => { @@ -213,6 +224,7 @@ describe('Filtered Search Manager', () => { }); it('should search with special characters', done => { + initializeManager(); input.value = '~!@#$%^&*()_+{}:<>,.?/'; visitUrl.mockImplementation(url => { @@ -225,7 +237,29 @@ describe('Filtered Search Manager', () => { manager.search(); }); + it('should use replacement URL for condition', done => { + initializeManager(); + tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML( + FilteredSearchSpecHelper.createFilterVisualTokenHTML('milestone', '=', '13', true), + ); + + visitUrl.mockImplementation(url => { + expect(url).toEqual(`${defaultParams}&milestone_title=replaced`); + done(); + }); + + manager.filteredSearchTokenKeys.conditions.push({ + url: 'milestone_title=13', + replacementUrl: 'milestone_title=replaced', + tokenKey: 'milestone', + value: '13', + operator: '=', + }); + manager.search(); + }); + it('removes duplicated tokens', done => { + initializeManager(); tokensContainer.innerHTML = FilteredSearchSpecHelper.createTokensContainerHTML(` ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} ${FilteredSearchSpecHelper.createFilterVisualTokenHTML('label', '=', '~bug')} diff --git a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js index e59ee925cc7..6a00065c9fe 100644 --- a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js @@ -280,8 +280,8 @@ describe('Filtered Search Visual Tokens', () => { ); }); - it('contains fa-close icon', () => { - expect(tokenElement.querySelector('.remove-token .fa-close')).toEqual(expect.anything()); + it('contains close icon', () => { + expect(tokenElement.querySelector('.remove-token .close-icon')).toEqual(expect.anything()); }); }); }); diff --git a/spec/frontend/filtered_search/services/recent_searches_service_spec.js b/spec/frontend/filtered_search/services/recent_searches_service_spec.js index a89d38b7a20..afeca54b949 100644 --- a/spec/frontend/filtered_search/services/recent_searches_service_spec.js +++ b/spec/frontend/filtered_search/services/recent_searches_service_spec.js @@ -1,7 +1,7 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; import AccessorUtilities from '~/lib/utils/accessor'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; useLocalStorageSpy(); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index 3a64b688c7a..e2855b29b70 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -1,10 +1,10 @@ import { escape } from 'lodash'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import VisualTokenValue from '~/filtered_search/visual_token_value'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; import DropdownUtils from '~/filtered_search//dropdown_utils'; import FilteredSearchSpecHelper from '../helpers/filtered_search_spec_helper'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Filtered Search Visual Tokens', () => { const findElements = tokenElement => { diff --git a/spec/frontend/fixtures/api_merge_requests.rb b/spec/frontend/fixtures/api_merge_requests.rb new file mode 100644 index 00000000000..f3280e216ff --- /dev/null +++ b/spec/frontend/fixtures/api_merge_requests.rb @@ -0,0 +1,24 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do + include ApiHelpers + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin, name: 'root') } + let(:namespace) { create(:namespace, name: 'gitlab-test' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } + + before(:all) do + clean_frontend_fixtures('api/merge_requests') + end + + it 'api/merge_requests/get.json' do + 4.times { |i| create(:merge_request, source_project: project, source_branch: "branch-#{i}") } + + get api("/projects/#{project.id}/merge_requests", admin) + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb new file mode 100644 index 00000000000..fa77ca1c0cf --- /dev/null +++ b/spec/frontend/fixtures/api_projects.rb @@ -0,0 +1,35 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe API::Projects, '(JavaScript fixtures)', type: :request do + include ApiHelpers + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin, name: 'root') } + let(:namespace) { create(:namespace, name: 'gitlab-test' )} + let(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } + let(:project_empty) { create(:project_empty_repo, namespace: namespace, path: 'lorem-ipsum-empty') } + + before(:all) do + clean_frontend_fixtures('api/projects') + end + + it 'api/projects/get.json' do + get api("/projects/#{project.id}", admin) + + expect(response).to be_successful + end + + it 'api/projects/get_empty.json' do + get api("/projects/#{project_empty.id}", admin) + + expect(response).to be_successful + end + + it 'api/projects/branches/get.json' do + get api("/projects/#{project.id}/repository/branches/#{project.default_branch}", admin) + + expect(response).to be_successful + end +end diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb new file mode 100644 index 00000000000..7695dbc2e8f --- /dev/null +++ b/spec/frontend/fixtures/freeze_period.rb @@ -0,0 +1,40 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Freeze Periods (JavaScript fixtures)' do + include JavaScriptFixturesHelpers + include Ci::PipelineSchedulesHelper + + let_it_be(:admin) { create(:admin) } + let_it_be(:project) { create(:project, :repository, path: 'freeze-periods-project') } + + before(:all) do + clean_frontend_fixtures('api/freeze-periods/') + end + + after(:all) do + remove_repository(project) + end + + describe API::FreezePeriods, '(JavaScript fixtures)', type: :request do + include ApiHelpers + + it 'api/freeze-periods/freeze_periods.json' do + create(:ci_freeze_period, project: project, freeze_start: '5 4 * * *', freeze_end: '5 9 * 8 *', cron_timezone: 'America/New_York') + create(:ci_freeze_period, project: project, freeze_start: '0 12 * * 1-5', freeze_end: '0 1 5 * *', cron_timezone: 'Etc/UTC') + create(:ci_freeze_period, project: project, freeze_start: '0 12 * * 1-5', freeze_end: '0 16 * * 6', cron_timezone: 'Europe/Berlin') + + get api("/projects/#{project.id}/freeze_periods", admin) + + expect(response).to be_successful + end + end + + describe Ci::PipelineSchedulesHelper, '(JavaScript fixtures)' do + let(:response) { timezone_data.to_json } + + it 'api/freeze-periods/timezone_data.json' do + end + end +end diff --git a/spec/frontend/fixtures/merge_requests.rb b/spec/frontend/fixtures/merge_requests.rb index 7801eb27ce8..6f281b26e6d 100644 --- a/spec/frontend/fixtures/merge_requests.rb +++ b/spec/frontend/fixtures/merge_requests.rb @@ -38,6 +38,7 @@ RSpec.describe Projects::MergeRequestsController, '(JavaScript fixtures)', type: sha: merge_request.diff_head_sha ) end + let(:path) { "files/ruby/popen.rb" } let(:position) do build(:text_diff_position, :added, diff --git a/spec/frontend/fixtures/metrics_dashboard.rb b/spec/frontend/fixtures/metrics_dashboard.rb index 6ee730f5c3d..eef79825ae7 100644 --- a/spec/frontend/fixtures/metrics_dashboard.rb +++ b/spec/frontend/fixtures/metrics_dashboard.rb @@ -8,7 +8,7 @@ RSpec.describe MetricsDashboard, '(JavaScript fixtures)', type: :controller do let_it_be(:user) { create(:user) } let_it_be(:namespace) { create(:namespace, name: 'monitoring' )} - let_it_be(:project) { project_with_dashboard_namespace('.gitlab/dashboards/test.yml', namespace: namespace) } + let_it_be(:project) { project_with_dashboard_namespace('.gitlab/dashboards/test.yml', nil, namespace: namespace) } let_it_be(:environment) { create(:environment, id: 1, project: project) } let_it_be(:params) { { environment: environment } } diff --git a/spec/frontend/fixtures/projects_json.rb b/spec/frontend/fixtures/projects_json.rb new file mode 100644 index 00000000000..c081d4f08dc --- /dev/null +++ b/spec/frontend/fixtures/projects_json.rb @@ -0,0 +1,47 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Projects JSON endpoints (JavaScript fixtures)', type: :controller do + include JavaScriptFixturesHelpers + + let(:admin) { create(:admin, name: 'root') } + let(:project) { create(:project, :repository) } + + before(:all) do + clean_frontend_fixtures('projects_json/') + end + + before do + project.add_maintainer(admin) + sign_in(admin) + end + + describe Projects::FindFileController, '(JavaScript fixtures)', type: :controller do + it 'projects_json/files.json' do + get :list, + params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: project.default_branch + }, + format: 'json' + + expect(response).to be_successful + end + end + + describe Projects::CommitController, '(JavaScript fixtures)', type: :controller do + it 'projects_json/pipelines_empty.json' do + get :pipelines, + params: { + namespace_id: project.namespace.to_param, + project_id: project, + id: project.commit(project.default_branch).id, + format: 'json' + } + + expect(response).to be_successful + end + end +end diff --git a/spec/frontend/fixtures/test_report.rb b/spec/frontend/fixtures/test_report.rb index 16496aa901b..3d09078ba68 100644 --- a/spec/frontend/fixtures/test_report.rb +++ b/spec/frontend/fixtures/test_report.rb @@ -15,7 +15,6 @@ RSpec.describe Projects::PipelinesController, "(JavaScript fixtures)", type: :co before do sign_in(user) - stub_feature_flags(junit_pipeline_view: project) end it "pipelines/test_report.json" do diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index fa7c1904339..a37d57b03fd 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -1,4 +1,10 @@ -import flash, { createFlashEl, createAction, hideFlash, removeFlashClickListener } from '~/flash'; +import createFlash, { + deprecatedCreateFlash, + createFlashEl, + createAction, + hideFlash, + removeFlashClickListener, +} from '~/flash'; describe('Flash', () => { describe('createFlashEl', () => { @@ -119,10 +125,10 @@ describe('Flash', () => { }); }); - describe('createFlash', () => { + describe('deprecatedCreateFlash', () => { describe('no flash-container', () => { it('does not add to the DOM', () => { - const flashEl = flash('testing'); + const flashEl = deprecatedCreateFlash('testing'); expect(flashEl).toBeNull(); @@ -144,7 +150,7 @@ describe('Flash', () => { }); it('adds flash element into container', () => { - flash('test', 'alert', document, null, false, true); + deprecatedCreateFlash('test', 'alert', document, null, false, true); expect(document.querySelector('.flash-alert')).not.toBeNull(); @@ -152,26 +158,26 @@ describe('Flash', () => { }); it('adds flash into specified parent', () => { - flash('test', 'alert', document.querySelector('.content-wrapper')); + deprecatedCreateFlash('test', 'alert', document.querySelector('.content-wrapper')); expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull(); }); it('adds container classes when inside content-wrapper', () => { - flash('test'); + deprecatedCreateFlash('test'); expect(document.querySelector('.flash-text').className).toBe('flash-text'); }); it('does not add container when outside of content-wrapper', () => { document.querySelector('.content-wrapper').className = 'js-content-wrapper'; - flash('test'); + deprecatedCreateFlash('test'); expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text'); }); it('removes element after clicking', () => { - flash('test', 'alert', document, null, false, true); + deprecatedCreateFlash('test', 'alert', document, null, false, true); document.querySelector('.flash-alert .js-close-icon').click(); @@ -182,8 +188,111 @@ describe('Flash', () => { describe('with actionConfig', () => { it('adds action link', () => { - flash('test', 'alert', document, { + deprecatedCreateFlash('test', 'alert', document, { + title: 'test', + }); + + expect(document.querySelector('.flash-action')).not.toBeNull(); + }); + + it('calls actionConfig clickHandler on click', () => { + const actionConfig = { title: 'test', + clickHandler: jest.fn(), + }; + + deprecatedCreateFlash('test', 'alert', document, actionConfig); + + document.querySelector('.flash-action').click(); + + expect(actionConfig.clickHandler).toHaveBeenCalled(); + }); + }); + }); + }); + + describe('createFlash', () => { + const message = 'test'; + const type = 'alert'; + const parent = document; + const fadeTransition = false; + const addBodyClass = true; + const defaultParams = { + message, + type, + parent, + actionConfig: null, + fadeTransition, + addBodyClass, + }; + + describe('no flash-container', () => { + it('does not add to the DOM', () => { + const flashEl = createFlash({ message }); + + expect(flashEl).toBeNull(); + + expect(document.querySelector('.flash-alert')).toBeNull(); + }); + }); + + describe('with flash-container', () => { + beforeEach(() => { + setFixtures( + '<div class="content-wrapper js-content-wrapper"><div class="flash-container"></div></div>', + ); + }); + + afterEach(() => { + document.querySelector('.js-content-wrapper').remove(); + }); + + it('adds flash element into container', () => { + createFlash({ ...defaultParams }); + + expect(document.querySelector('.flash-alert')).not.toBeNull(); + + expect(document.body.className).toContain('flash-shown'); + }); + + it('adds flash into specified parent', () => { + createFlash({ ...defaultParams, parent: document.querySelector('.content-wrapper') }); + + expect(document.querySelector('.content-wrapper .flash-alert')).not.toBeNull(); + expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); + }); + + it('adds container classes when inside content-wrapper', () => { + createFlash(defaultParams); + + expect(document.querySelector('.flash-text').className).toBe('flash-text'); + expect(document.querySelector('.content-wrapper').innerText.trim()).toEqual(message); + }); + + it('does not add container when outside of content-wrapper', () => { + document.querySelector('.content-wrapper').className = 'js-content-wrapper'; + createFlash(defaultParams); + + expect(document.querySelector('.flash-text').className.trim()).toContain('flash-text'); + }); + + it('removes element after clicking', () => { + createFlash({ ...defaultParams }); + + document.querySelector('.flash-alert .js-close-icon').click(); + + expect(document.querySelector('.flash-alert')).toBeNull(); + + expect(document.body.className).not.toContain('flash-shown'); + }); + + describe('with actionConfig', () => { + it('adds action link', () => { + createFlash({ + ...defaultParams, + actionConfig: { + title: 'test', + }, }); expect(document.querySelector('.flash-action')).not.toBeNull(); @@ -195,7 +304,7 @@ describe('Flash', () => { clickHandler: jest.fn(), }; - flash('test', 'alert', document, actionConfig); + createFlash({ ...defaultParams, actionConfig }); document.querySelector('.flash-action').click(); diff --git a/spec/frontend/frequent_items/components/app_spec.js b/spec/frontend/frequent_items/components/app_spec.js index 7c54a48aa41..b4f36b82385 100644 --- a/spec/frontend/frequent_items/components/app_spec.js +++ b/spec/frontend/frequent_items/components/app_spec.js @@ -1,6 +1,8 @@ import MockAdapter from 'axios-mock-adapter'; import Vue from 'vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import appComponent from '~/frequent_items/components/app.vue'; import eventHub from '~/frequent_items/event_hub'; @@ -8,8 +10,6 @@ import store from '~/frequent_items/store'; import { FREQUENT_ITEMS, HOUR_IN_MS } from '~/frequent_items/constants'; import { getTopFrequentItems } from '~/frequent_items/utils'; import { currentSession, mockFrequentProjects, mockSearchedProjects } from '../mock_data'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; -import waitForPromises from 'helpers/wait_for_promises'; useLocalStorageSpy(); 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 1595f6c9fff..0e16b726c4b 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 @@ -12,17 +12,19 @@ exports[`grafana integration component default state to match the default snapsh class="js-section-header h4" > - Grafana Authentication + Grafana authentication </h3> - <gl-deprecated-button-stub + <gl-button-stub + category="primary" class="js-settings-toggle" - size="md" - variant="secondary" + icon="" + size="medium" + variant="default" > Expand - </gl-deprecated-button-stub> + </gl-button-stub> <p class="js-section-sub-header" @@ -90,14 +92,20 @@ exports[`grafana integration component default state to match the default snapsh </p> </gl-form-group-stub> - <gl-deprecated-button-stub - size="md" - variant="success" + <div + class="gl-display-flex gl-justify-content-end" > + <gl-button-stub + category="primary" + icon="" + size="medium" + variant="success" + > + + Save Changes - Save Changes - - </gl-deprecated-button-stub> + </gl-button-stub> + </div> </form> </div> </section> diff --git a/spec/frontend/grafana_integration/components/grafana_integration_spec.js b/spec/frontend/grafana_integration/components/grafana_integration_spec.js index 3df200a98e4..df88a336c09 100644 --- a/spec/frontend/grafana_integration/components/grafana_integration_spec.js +++ b/spec/frontend/grafana_integration/components/grafana_integration_spec.js @@ -1,11 +1,11 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import GrafanaIntegration from '~/grafana_integration/components/grafana_integration.vue'; import { createStore } from '~/grafana_integration/store'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/flash'); @@ -44,14 +44,14 @@ describe('grafana integration component', () => { it('renders header text', () => { wrapper = shallowMount(GrafanaIntegration, { store }); - expect(wrapper.find('.js-section-header').text()).toBe('Grafana Authentication'); + expect(wrapper.find('.js-section-header').text()).toBe('Grafana authentication'); }); describe('expand/collapse button', () => { it('renders as an expand button by default', () => { wrapper = shallowMount(GrafanaIntegration, { store }); - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); expect(button.text()).toBe('Expand'); }); @@ -77,8 +77,7 @@ describe('grafana integration component', () => { }); describe('submit button', () => { - const findSubmitButton = () => - wrapper.find('.settings-content form').find(GlDeprecatedButton); + const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton); const endpointRequest = [ operationsSettingsEndpoint, diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 35eda21e047..5d34bc48ed5 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -2,8 +2,8 @@ import '~/flash'; import $ from 'jquery'; import Vue from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; import appComponent from '~/groups/components/app.vue'; import groupFolderComponent from '~/groups/components/group_folder.vue'; import groupItemComponent from '~/groups/components/group_item.vue'; diff --git a/spec/frontend/header_spec.js b/spec/frontend/header_spec.js index 467d9678f69..59a8ca2ed23 100644 --- a/spec/frontend/header_spec.js +++ b/spec/frontend/header_spec.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import initTodoToggle, { initNavUserDropdownTracking } from '~/header'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import initTodoToggle, { initNavUserDropdownTracking } from '~/header'; describe('Header', () => { describe('Todos notification', () => { diff --git a/spec/frontend/helpers/backoff_helper.js b/spec/frontend/helpers/backoff_helper.js new file mode 100644 index 00000000000..e5c0308d3fb --- /dev/null +++ b/spec/frontend/helpers/backoff_helper.js @@ -0,0 +1,33 @@ +/** + * A mock version of a commonUtils `backOff` to test multiple + * retries. + * + * Usage: + * + * ``` + * import * as commonUtils from '~/lib/utils/common_utils'; + * import { backoffMockImplementation } from '../../helpers/backoff_helper'; + * + * beforeEach(() => { + * // ... + * jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); + * }); + * ``` + * + * @param {Function} callback + */ +export const backoffMockImplementation = callback => { + const q = new Promise((resolve, reject) => { + const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); + const next = () => callback(next, stop); + // Define a timeout based on a mock timer + setTimeout(() => { + callback(next, stop); + }); + }); + // Run all resolved promises in chain + jest.runOnlyPendingTimers(); + return q; +}; + +export default { backoffMockImplementation }; diff --git a/spec/frontend/helpers/dom_events_helper.js b/spec/frontend/helpers/dom_events_helper.js index b66c12daf4f..139e0813397 100644 --- a/spec/frontend/helpers/dom_events_helper.js +++ b/spec/frontend/helpers/dom_events_helper.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export export const triggerDOMEvent = type => { window.document.dispatchEvent( new Event(type, { @@ -6,5 +7,3 @@ export const triggerDOMEvent = type => { }), ); }; - -export default () => {}; diff --git a/spec/frontend/helpers/dom_shims/index.js b/spec/frontend/helpers/dom_shims/index.js index d18bb94c107..2ba5701fc77 100644 --- a/spec/frontend/helpers/dom_shims/index.js +++ b/spec/frontend/helpers/dom_shims/index.js @@ -4,7 +4,7 @@ import './element_scroll_to'; import './form_element'; import './get_client_rects'; import './inner_text'; -import './mutation_observer'; +import './range'; import './window_scroll_to'; import './scroll_by'; import './size_properties'; diff --git a/spec/frontend/helpers/dom_shims/mutation_observer.js b/spec/frontend/helpers/dom_shims/mutation_observer.js deleted file mode 100644 index 68c494f19ea..00000000000 --- a/spec/frontend/helpers/dom_shims/mutation_observer.js +++ /dev/null @@ -1,7 +0,0 @@ -/* eslint-disable class-methods-use-this */ -class MutationObserverStub { - disconnect() {} - observe() {} -} - -global.MutationObserver = MutationObserverStub; diff --git a/spec/frontend/helpers/dom_shims/range.js b/spec/frontend/helpers/dom_shims/range.js new file mode 100644 index 00000000000..4ffdf3280ad --- /dev/null +++ b/spec/frontend/helpers/dom_shims/range.js @@ -0,0 +1,13 @@ +if (window.Range.prototype.getBoundingClientRect) { + throw new Error('window.Range.prototype.getBoundingClientRect already exists. Remove this stub!'); +} +window.Range.prototype.getBoundingClientRect = function getBoundingClientRect() { + return { x: 0, y: 0, width: 0, height: 0, top: 0, right: 0, bottom: 0, left: 0 }; +}; + +if (window.Range.prototype.getClientRects) { + throw new Error('window.Range.prototype.getClientRects already exists. Remove this stub!'); +} +window.Range.prototype.getClientRects = function getClientRects() { + return [this.getBoundingClientRect()]; +}; diff --git a/spec/frontend/helpers/filtered_search_spec_helper.js b/spec/frontend/helpers/filtered_search_spec_helper.js index ceb7982bbc3..ecf10694a16 100644 --- a/spec/frontend/helpers/filtered_search_spec_helper.js +++ b/spec/frontend/helpers/filtered_search_spec_helper.js @@ -15,7 +15,7 @@ export default class FilteredSearchSpecHelper { <div class="value-container"> <div class="value">${value}</div> <div class="remove-token" role="button"> - <i class="fa fa-close"></i> + <svg class="s16 close-icon"></svg> </div> </div> </div> diff --git a/spec/frontend/helpers/init_vue_mr_page_helper.js b/spec/frontend/helpers/init_vue_mr_page_helper.js index c1d608cc5a0..b9aed63d0f6 100644 --- a/spec/frontend/helpers/init_vue_mr_page_helper.js +++ b/spec/frontend/helpers/init_vue_mr_page_helper.js @@ -22,6 +22,7 @@ export default function initVueMRPage() { mrDiscussionsEl.setAttribute('data-noteable-data', JSON.stringify(noteableDataMock)); mrDiscussionsEl.setAttribute('data-notes-data', JSON.stringify(notesDataMock)); mrDiscussionsEl.setAttribute('data-noteable-type', 'merge-request'); + mrDiscussionsEl.setAttribute('data-is-locked', 'false'); mrTestEl.appendChild(mrDiscussionsEl); const discussionCounterEl = document.createElement('div'); diff --git a/spec/frontend/helpers/monitor_helper_spec.js b/spec/frontend/helpers/monitor_helper_spec.js index 083b6404125..219b05e312b 100644 --- a/spec/frontend/helpers/monitor_helper_spec.js +++ b/spec/frontend/helpers/monitor_helper_spec.js @@ -1,12 +1,38 @@ -import * as monitorHelper from '~/helpers/monitor_helper'; +import { getSeriesLabel, makeDataSeries } from '~/helpers/monitor_helper'; describe('monitor helper', () => { const defaultConfig = { default: true, name: 'default name' }; const name = 'data name'; const series = [[1, 1], [2, 2], [3, 3]]; - const data = ({ metric = { default_name: name }, values = series } = {}) => [{ metric, values }]; + + describe('getSeriesLabel', () => { + const metricAttributes = { __name__: 'up', app: 'prometheus' }; + + it('gets a single attribute label', () => { + expect(getSeriesLabel('app', metricAttributes)).toBe('app: prometheus'); + }); + + it('gets a templated label', () => { + expect(getSeriesLabel('{{__name__}}', metricAttributes)).toBe('up'); + expect(getSeriesLabel('{{app}}', metricAttributes)).toBe('prometheus'); + expect(getSeriesLabel('{{missing}}', metricAttributes)).toBe('{{missing}}'); + }); + + it('gets a multiple label', () => { + expect(getSeriesLabel(null, metricAttributes)).toBe('__name__: up, app: prometheus'); + expect(getSeriesLabel('', metricAttributes)).toBe('__name__: up, app: prometheus'); + }); + + it('gets a simple label', () => { + expect(getSeriesLabel('A label', {})).toBe('A label'); + }); + }); describe('makeDataSeries', () => { + const data = ({ metric = { default_name: name }, values = series } = {}) => [ + { metric, values }, + ]; + const expectedDataSeries = [ { ...defaultConfig, @@ -15,19 +41,17 @@ describe('monitor helper', () => { ]; it('converts query results to data series', () => { - expect(monitorHelper.makeDataSeries(data({ metric: {} }), defaultConfig)).toEqual( - expectedDataSeries, - ); + expect(makeDataSeries(data({ metric: {} }), defaultConfig)).toEqual(expectedDataSeries); }); it('returns an empty array if no query results exist', () => { - expect(monitorHelper.makeDataSeries([], defaultConfig)).toEqual([]); + expect(makeDataSeries([], defaultConfig)).toEqual([]); }); it('handles multi-series query results', () => { const expectedData = { ...expectedDataSeries[0], name: 'default name: data name' }; - expect(monitorHelper.makeDataSeries([...data(), ...data()], defaultConfig)).toEqual([ + expect(makeDataSeries([...data(), ...data()], defaultConfig)).toEqual([ expectedData, expectedData, ]); @@ -39,10 +63,7 @@ describe('monitor helper', () => { name: '{{cmd}}', }; - const [result] = monitorHelper.makeDataSeries( - [{ metric: { cmd: 'brpop' }, values: series }], - config, - ); + const [result] = makeDataSeries([{ metric: { cmd: 'brpop' }, values: series }], config); expect(result.name).toEqual('brpop'); }); @@ -53,7 +74,7 @@ describe('monitor helper', () => { name: '', }; - const [result] = monitorHelper.makeDataSeries( + const [result] = makeDataSeries( [ { metric: { @@ -79,7 +100,7 @@ describe('monitor helper', () => { name: 'backend: {{ backend }}', }; - const [result] = monitorHelper.makeDataSeries( + const [result] = makeDataSeries( [{ metric: { backend: 'HA Server' }, values: series }], config, ); @@ -90,10 +111,7 @@ describe('monitor helper', () => { it('supports repeated template variables', () => { const config = { ...defaultConfig, name: '{{cmd}}, {{cmd}}' }; - const [result] = monitorHelper.makeDataSeries( - [{ metric: { cmd: 'brpop' }, values: series }], - config, - ); + const [result] = makeDataSeries([{ metric: { cmd: 'brpop' }, values: series }], config); expect(result.name).toEqual('brpop, brpop'); }); @@ -101,7 +119,7 @@ describe('monitor helper', () => { it('supports hyphenated template variables', () => { const config = { ...defaultConfig, name: 'expired - {{ test-attribute }}' }; - const [result] = monitorHelper.makeDataSeries( + const [result] = makeDataSeries( [{ metric: { 'test-attribute': 'test-attribute-value' }, values: series }], config, ); @@ -115,7 +133,7 @@ describe('monitor helper', () => { name: '{{job}}: {{cmd}}', }; - const [result] = monitorHelper.makeDataSeries( + const [result] = makeDataSeries( [{ metric: { cmd: 'brpop', job: 'redis' }, values: series }], config, ); @@ -129,7 +147,7 @@ describe('monitor helper', () => { name: '{{cmd}}', }; - const [firstSeries, secondSeries] = monitorHelper.makeDataSeries( + const [firstSeries, secondSeries] = makeDataSeries( [ { metric: { cmd: 'brpop' }, values: series }, { metric: { cmd: 'zrangebyscore' }, values: series }, diff --git a/spec/frontend/ide/components/activity_bar_spec.js b/spec/frontend/ide/components/activity_bar_spec.js index 8b3853d4535..762f3c5dad1 100644 --- a/spec/frontend/ide/components/activity_bar_spec.js +++ b/spec/frontend/ide/components/activity_bar_spec.js @@ -1,15 +1,17 @@ import Vue from 'vue'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import { leftSidebarViews } from '~/ide/constants'; import ActivityBar from '~/ide/components/activity_bar.vue'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { resetStore } from '../helpers'; describe('IDE activity bar', () => { const Component = Vue.extend(ActivityBar); let vm; + let store; beforeEach(() => { + store = createStore(); + Vue.set(store.state.projects, 'abcproject', { web_url: 'testing', }); @@ -20,8 +22,6 @@ describe('IDE activity bar', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); describe('updateActivityBarView', () => { diff --git a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js index 16d0b354a30..dbb43e43c19 100644 --- a/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/empty_state_spec.js @@ -1,13 +1,15 @@ import Vue from 'vue'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import emptyState from '~/ide/components/commit_sidebar/empty_state.vue'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; -import { resetStore } from '../../helpers'; describe('IDE commit panel empty state', () => { let vm; + let store; beforeEach(() => { + store = createStore(); + const Component = Vue.extend(emptyState); Vue.set(store.state, 'noChangesStateSvgPath', 'no-changes'); @@ -19,8 +21,6 @@ describe('IDE commit panel empty state', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders no changes text when last commit message is empty', () => { diff --git a/spec/frontend/ide/components/commit_sidebar/form_spec.js b/spec/frontend/ide/components/commit_sidebar/form_spec.js index c62df4a3795..9245cefc183 100644 --- a/spec/frontend/ide/components/commit_sidebar/form_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/form_spec.js @@ -1,19 +1,20 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { projectData } from 'jest/ide/mock_data'; -import store from '~/ide/stores'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createStore } from '~/ide/stores'; import CommitForm from '~/ide/components/commit_sidebar/form.vue'; import { leftSidebarViews } from '~/ide/constants'; -import { resetStore } from '../../helpers'; -import waitForPromises from 'helpers/wait_for_promises'; describe('IDE commit form', () => { const Component = Vue.extend(CommitForm); let vm; + let store; const beginCommitButton = () => vm.$el.querySelector('[data-testid="begin-commit-button"]'); beforeEach(() => { + store = createStore(); store.state.changedFiles.push('test'); store.state.currentProjectId = 'abcproject'; store.state.currentBranchId = 'master'; @@ -24,8 +25,6 @@ describe('IDE commit form', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('enables begin commit button when there are changes', () => { diff --git a/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js b/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js index 45372d18965..42e0a20bc7b 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_collapsed_spec.js @@ -1,14 +1,17 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import listCollapsed from '~/ide/components/commit_sidebar/list_collapsed.vue'; import { file } from '../../helpers'; import { removeWhitespace } from '../../../helpers/text_helper'; describe('Multi-file editor commit sidebar list collapsed', () => { let vm; + let store; beforeEach(() => { + store = createStore(); + const Component = Vue.extend(listCollapsed); vm = createComponentWithStore(Component, store, { diff --git a/spec/frontend/ide/components/commit_sidebar/list_spec.js b/spec/frontend/ide/components/commit_sidebar/list_spec.js index 2b5664ffc4e..2107ff96e95 100644 --- a/spec/frontend/ide/components/commit_sidebar/list_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/list_spec.js @@ -1,13 +1,16 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import commitSidebarList from '~/ide/components/commit_sidebar/list.vue'; -import { file, resetStore } from '../../helpers'; +import { file } from '../../helpers'; describe('Multi-file editor commit sidebar list', () => { + let store; let vm; beforeEach(() => { + store = createStore(); + const Component = Vue.extend(commitSidebarList); vm = createComponentWithStore(Component, store, { @@ -26,8 +29,6 @@ describe('Multi-file editor commit sidebar list', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); describe('with a list of files', () => { diff --git a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js index ac80ba58056..bf61f4bbe77 100644 --- a/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/radio_group_spec.js @@ -1,13 +1,15 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import { resetStore } from 'jest/ide/helpers'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import radioGroup from '~/ide/components/commit_sidebar/radio_group.vue'; describe('IDE commit sidebar radio group', () => { let vm; + let store; beforeEach(done => { + store = createStore(); + const Component = Vue.extend(radioGroup); store.state.commit.commitAction = '2'; @@ -25,8 +27,6 @@ describe('IDE commit sidebar radio group', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('uses label if present', () => { diff --git a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js index e1a432b81be..db13c90fbb9 100644 --- a/spec/frontend/ide/components/commit_sidebar/success_message_spec.js +++ b/spec/frontend/ide/components/commit_sidebar/success_message_spec.js @@ -1,13 +1,15 @@ import Vue from 'vue'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import successMessage from '~/ide/components/commit_sidebar/success_message.vue'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; -import { resetStore } from '../../helpers'; describe('IDE commit panel successful commit state', () => { let vm; + let store; beforeEach(() => { + store = createStore(); + const Component = Vue.extend(successMessage); vm = createComponentWithStore(Component, store, { @@ -19,8 +21,6 @@ describe('IDE commit panel successful commit state', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders last commit message when it exists', done => { diff --git a/spec/frontend/ide/components/file_row_extra_spec.js b/spec/frontend/ide/components/file_row_extra_spec.js index e78bacadebb..4bd27d23f76 100644 --- a/spec/frontend/ide/components/file_row_extra_spec.js +++ b/spec/frontend/ide/components/file_row_extra_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createStore } from '~/ide/stores'; import FileRowExtra from '~/ide/components/file_row_extra.vue'; -import { file, resetStore } from '../helpers'; +import { file } from '../helpers'; describe('IDE extra file row component', () => { let Component; @@ -32,7 +32,6 @@ describe('IDE extra file row component', () => { afterEach(() => { vm.$destroy(); - resetStore(vm.$store); stagedFilesCount = 0; unstagedFilesCount = 0; diff --git a/spec/frontend/ide/components/file_templates/bar_spec.js b/spec/frontend/ide/components/file_templates/bar_spec.js index 21dbe18a223..5a33837fb14 100644 --- a/spec/frontend/ide/components/file_templates/bar_spec.js +++ b/spec/frontend/ide/components/file_templates/bar_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createStore } from '~/ide/stores'; import Bar from '~/ide/components/file_templates/bar.vue'; -import { resetStore, file } from '../../helpers'; +import { file } from '../../helpers'; describe('IDE file templates bar component', () => { let Component; @@ -26,7 +26,6 @@ describe('IDE file templates bar component', () => { afterEach(() => { vm.$destroy(); - resetStore(vm.$store); }); describe('template type dropdown', () => { diff --git a/spec/frontend/ide/components/ide_review_spec.js b/spec/frontend/ide/components/ide_review_spec.js index b56957e1f6d..c9ac2ac423d 100644 --- a/spec/frontend/ide/components/ide_review_spec.js +++ b/spec/frontend/ide/components/ide_review_spec.js @@ -3,7 +3,7 @@ import IdeReview from '~/ide/components/ide_review.vue'; import { createStore } from '~/ide/stores'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; import { trimText } from '../../helpers/text_helper'; -import { resetStore, file } from '../helpers'; +import { file } from '../helpers'; import { projectData } from '../mock_data'; describe('IDE review mode', () => { @@ -26,8 +26,6 @@ describe('IDE review mode', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders list of files', () => { diff --git a/spec/frontend/ide/components/ide_side_bar_spec.js b/spec/frontend/ide/components/ide_side_bar_spec.js index 65cad2e7eb0..67257b40879 100644 --- a/spec/frontend/ide/components/ide_side_bar_spec.js +++ b/spec/frontend/ide/components/ide_side_bar_spec.js @@ -1,15 +1,17 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import ideSidebar from '~/ide/components/ide_side_bar.vue'; import { leftSidebarViews } from '~/ide/constants'; -import { resetStore } from '../helpers'; import { projectData } from '../mock_data'; describe('IdeSidebar', () => { let vm; + let store; beforeEach(() => { + store = createStore(); + const Component = Vue.extend(ideSidebar); store.state.currentProjectId = 'abcproject'; @@ -20,8 +22,6 @@ describe('IdeSidebar', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders a sidebar', () => { diff --git a/spec/frontend/ide/components/ide_spec.js b/spec/frontend/ide/components/ide_spec.js index efc1d984dec..a7b07a9f0e2 100644 --- a/spec/frontend/ide/components/ide_spec.js +++ b/spec/frontend/ide/components/ide_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createStore } from '~/ide/stores'; import ide from '~/ide/components/ide.vue'; -import { file, resetStore } from '../helpers'; +import { file } from '../helpers'; import { projectData } from '../mock_data'; import extendStore from '~/ide/stores/extend'; @@ -41,8 +41,6 @@ describe('ide component, empty repo', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders "New file" button in empty repo', done => { @@ -63,8 +61,6 @@ describe('ide component, non-empty repo', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('shows error message when set', done => { diff --git a/spec/frontend/ide/components/ide_tree_list_spec.js b/spec/frontend/ide/components/ide_tree_list_spec.js index 30f11db3153..4593ef6049b 100644 --- a/spec/frontend/ide/components/ide_tree_list_spec.js +++ b/spec/frontend/ide/components/ide_tree_list_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import IdeTreeList from '~/ide/components/ide_tree_list.vue'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { resetStore, file } from '../helpers'; +import { file } from '../helpers'; import { projectData } from '../mock_data'; describe('IDE tree list', () => { @@ -10,6 +10,7 @@ describe('IDE tree list', () => { const normalBranchTree = [file('fileName')]; const emptyBranchTree = []; let vm; + let store; const bootstrapWithTree = (tree = normalBranchTree) => { store.state.currentProjectId = 'abcproject'; @@ -25,10 +26,12 @@ describe('IDE tree list', () => { }); }; + beforeEach(() => { + store = createStore(); + }); + afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); describe('normal branch', () => { diff --git a/spec/frontend/ide/components/ide_tree_spec.js b/spec/frontend/ide/components/ide_tree_spec.js index 01f007f09c3..899daa0bf57 100644 --- a/spec/frontend/ide/components/ide_tree_spec.js +++ b/spec/frontend/ide/components/ide_tree_spec.js @@ -1,14 +1,17 @@ import Vue from 'vue'; import IdeTree from '~/ide/components/ide_tree.vue'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import { resetStore, file } from '../helpers'; +import { file } from '../helpers'; import { projectData } from '../mock_data'; describe('IdeRepoTree', () => { + let store; let vm; beforeEach(() => { + store = createStore(); + const IdeRepoTree = Vue.extend(IdeTree); store.state.currentProjectId = 'abcproject'; @@ -24,8 +27,6 @@ describe('IdeRepoTree', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders list of files', () => { diff --git a/spec/frontend/ide/components/jobs/detail_spec.js b/spec/frontend/ide/components/jobs/detail_spec.js index 8f3815d5aab..acd30dee718 100644 --- a/spec/frontend/ide/components/jobs/detail_spec.js +++ b/spec/frontend/ide/components/jobs/detail_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; +import { TEST_HOST } from 'helpers/test_constants'; import JobDetail from '~/ide/components/jobs/detail.vue'; import { createStore } from '~/ide/stores'; import { createComponentWithStore } from '../../../helpers/vue_mount_component_helper'; import { jobs } from '../../mock_data'; -import { TEST_HOST } from 'helpers/test_constants'; describe('IDE jobs detail view', () => { let vm; diff --git a/spec/frontend/ide/components/new_dropdown/index_spec.js b/spec/frontend/ide/components/new_dropdown/index_spec.js index 00781c16609..c6cebf36de3 100644 --- a/spec/frontend/ide/components/new_dropdown/index_spec.js +++ b/spec/frontend/ide/components/new_dropdown/index_spec.js @@ -1,13 +1,15 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; -import store from '~/ide/stores'; +import { createStore } from '~/ide/stores'; import newDropdown from '~/ide/components/new_dropdown/index.vue'; -import { resetStore } from '../../helpers'; describe('new dropdown component', () => { + let store; let vm; beforeEach(() => { + store = createStore(); + const component = Vue.extend(newDropdown); vm = createComponentWithStore(component, store, { @@ -30,8 +32,6 @@ describe('new dropdown component', () => { afterEach(() => { vm.$destroy(); - - resetStore(vm.$store); }); it('renders new file, upload and new directory links', () => { diff --git a/spec/frontend/ide/components/new_dropdown/modal_spec.js b/spec/frontend/ide/components/new_dropdown/modal_spec.js index da17cc3601e..ea8ba24c9d0 100644 --- a/spec/frontend/ide/components/new_dropdown/modal_spec.js +++ b/spec/frontend/ide/components/new_dropdown/modal_spec.js @@ -2,7 +2,7 @@ import Vue from 'vue'; import { createComponentWithStore } from 'helpers/vue_mount_component_helper'; import { createStore } from '~/ide/stores'; import modal from '~/ide/components/new_dropdown/modal.vue'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); diff --git a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js index e32abc98aae..bb9ba32a699 100644 --- a/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js +++ b/spec/frontend/ide/components/panes/collapsible_sidebar_spec.js @@ -1,9 +1,9 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; +import Vuex from 'vuex'; import { createStore } from '~/ide/stores'; import paneModule from '~/ide/stores/modules/pane'; import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; import IdeSidebarNav from '~/ide/components/ide_sidebar_nav.vue'; -import Vuex from 'vuex'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 795ded35d20..86cdbafaff9 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -2,11 +2,11 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { GlLoadingIcon } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; +import { pipelines } from 'jest/ide/mock_data'; import List from '~/ide/components/pipelines/list.vue'; import JobsList from '~/ide/components/jobs/list.vue'; import Tab from '~/vue_shared/components/tabs/tab.vue'; import CiIcon from '~/vue_shared/components/ci_icon.vue'; -import { pipelines } from 'jest/ide/mock_data'; import IDEServices from '~/ide/services'; const localVue = createLocalVue(); diff --git a/spec/frontend/ide/components/preview/navigator_spec.js b/spec/frontend/ide/components/preview/navigator_spec.js index aa15f391e77..ba5ac3bbbea 100644 --- a/spec/frontend/ide/components/preview/navigator_spec.js +++ b/spec/frontend/ide/components/preview/navigator_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; import { GlLoadingIcon } from '@gitlab/ui'; -import ClientsideNavigator from '~/ide/components/preview/navigator.vue'; import { listen } from 'codesandbox-api'; +import ClientsideNavigator from '~/ide/components/preview/navigator.vue'; jest.mock('codesandbox-api', () => ({ listen: jest.fn().mockReturnValue(jest.fn()), diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index a4336b8f2eb..f0ae2ba732b 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -3,6 +3,8 @@ import Vue from 'vue'; import MockAdapter from 'axios-mock-adapter'; import '~/behaviors/markdown/render_gfm'; import { Range } from 'monaco-editor'; +import waitForPromises from 'helpers/wait_for_promises'; +import waitUsingRealTimer from 'helpers/wait_using_real_timer'; import axios from '~/lib/utils/axios_utils'; import service from '~/ide/services'; import { createStoreOptions } from '~/ide/stores'; @@ -15,10 +17,8 @@ import { viewerTypes, } from '~/ide/constants'; import { createComponentWithStore } from '../../helpers/vue_mount_component_helper'; -import waitForPromises from 'helpers/wait_for_promises'; import { file } from '../helpers'; import { exampleConfigs, exampleFiles } from '../lib/editorconfig/mock_data'; -import waitUsingRealTimer from 'helpers/wait_using_real_timer'; describe('RepoEditor', () => { let vm; diff --git a/spec/frontend/ide/helpers.js b/spec/frontend/ide/helpers.js index a9620d26313..8caa9c2b437 100644 --- a/spec/frontend/ide/helpers.js +++ b/spec/frontend/ide/helpers.js @@ -1,25 +1,5 @@ import * as pathUtils from 'path'; import { decorateData } from '~/ide/stores/utils'; -import state from '~/ide/stores/state'; -import commitState from '~/ide/stores/modules/commit/state'; -import mergeRequestsState from '~/ide/stores/modules/merge_requests/state'; -import pipelinesState from '~/ide/stores/modules/pipelines/state'; -import branchesState from '~/ide/stores/modules/branches/state'; -import fileTemplatesState from '~/ide/stores/modules/file_templates/state'; -import paneState from '~/ide/stores/modules/pane/state'; - -export const resetStore = store => { - const newState = { - ...state(), - commit: commitState(), - mergeRequests: mergeRequestsState(), - pipelines: pipelinesState(), - branches: branchesState(), - fileTemplates: fileTemplatesState(), - rightPane: paneState(), - }; - store.replaceState(newState); -}; export const file = (name = 'name', id = name, type = '', parent = null) => decorateData({ diff --git a/spec/frontend/ide/ide_router_spec.js b/spec/frontend/ide/ide_router_spec.js index b53e2019819..a4fe00883cf 100644 --- a/spec/frontend/ide/ide_router_spec.js +++ b/spec/frontend/ide/ide_router_spec.js @@ -1,6 +1,6 @@ +import waitForPromises from 'helpers/wait_for_promises'; import { createRouter } from '~/ide/ide_router'; import { createStore } from '~/ide/stores'; -import waitForPromises from 'helpers/wait_for_promises'; describe('IDE router', () => { const PROJECT_NAMESPACE = 'my-group/sub-group'; diff --git a/spec/frontend/ide/lib/decorations/controller_spec.js b/spec/frontend/ide/lib/decorations/controller_spec.js index 4556fc9d646..e9b7faaadfe 100644 --- a/spec/frontend/ide/lib/decorations/controller_spec.js +++ b/spec/frontend/ide/lib/decorations/controller_spec.js @@ -2,14 +2,17 @@ import Editor from '~/ide/lib/editor'; import DecorationsController from '~/ide/lib/decorations/controller'; import Model from '~/ide/lib/common/model'; import { file } from '../../helpers'; +import { createStore } from '~/ide/stores'; describe('Multi-file editor library decorations controller', () => { let editorInstance; let controller; let model; + let store; beforeEach(() => { - editorInstance = Editor.create(); + store = createStore(); + editorInstance = Editor.create(store); editorInstance.createInstance(document.createElement('div')); controller = new DecorationsController(editorInstance); diff --git a/spec/frontend/ide/lib/diff/controller_spec.js b/spec/frontend/ide/lib/diff/controller_spec.js index 0b33a4c6ad6..8ee6388a760 100644 --- a/spec/frontend/ide/lib/diff/controller_spec.js +++ b/spec/frontend/ide/lib/diff/controller_spec.js @@ -4,6 +4,7 @@ import ModelManager from '~/ide/lib/common/model_manager'; import DecorationsController from '~/ide/lib/decorations/controller'; import DirtyDiffController, { getDiffChangeType, getDecorator } from '~/ide/lib/diff/controller'; import { computeDiff } from '~/ide/lib/diff/diff'; +import { createStore } from '~/ide/stores'; import { file } from '../../helpers'; describe('Multi-file editor library dirty diff controller', () => { @@ -12,9 +13,12 @@ describe('Multi-file editor library dirty diff controller', () => { let modelManager; let decorationsController; let model; + let store; beforeEach(() => { - editorInstance = Editor.create(); + store = createStore(); + + editorInstance = Editor.create(store); editorInstance.createInstance(document.createElement('div')); modelManager = new ModelManager(); diff --git a/spec/frontend/ide/lib/editor_spec.js b/spec/frontend/ide/lib/editor_spec.js index 5f28309422d..529f80e6f6f 100644 --- a/spec/frontend/ide/lib/editor_spec.js +++ b/spec/frontend/ide/lib/editor_spec.js @@ -5,6 +5,7 @@ import { Selection, } from 'monaco-editor'; import Editor from '~/ide/lib/editor'; +import { createStore } from '~/ide/stores'; import { defaultEditorOptions } from '~/ide/lib/editor_options'; import { file } from '../helpers'; @@ -12,6 +13,7 @@ describe('Multi-file editor library', () => { let instance; let el; let holder; + let store; const setNodeOffsetWidth = val => { Object.defineProperty(instance.instance.getDomNode(), 'offsetWidth', { @@ -22,13 +24,14 @@ describe('Multi-file editor library', () => { }; beforeEach(() => { + store = createStore(); el = document.createElement('div'); holder = document.createElement('div'); el.appendChild(holder); document.body.appendChild(el); - instance = Editor.create(); + instance = Editor.create(store); }); afterEach(() => { @@ -44,7 +47,7 @@ describe('Multi-file editor library', () => { }); it('creates instance returns cached instance', () => { - expect(Editor.create()).toEqual(instance); + expect(Editor.create(store)).toEqual(instance); }); describe('createInstance', () => { diff --git a/spec/frontend/ide/lib/languages/vue_spec.js b/spec/frontend/ide/lib/languages/vue_spec.js index 3d8784c1436..ba5c31bb101 100644 --- a/spec/frontend/ide/lib/languages/vue_spec.js +++ b/spec/frontend/ide/lib/languages/vue_spec.js @@ -9,7 +9,7 @@ describe('tokenization for .vue files', () => { registerLanguages(vue); }); - test.each([ + it.each([ [ '<div v-if="something">content</div>', [ diff --git a/spec/frontend/ide/stores/actions/merge_request_spec.js b/spec/frontend/ide/stores/actions/merge_request_spec.js index e5c4f346459..62971b9cad6 100644 --- a/spec/frontend/ide/stores/actions/merge_request_spec.js +++ b/spec/frontend/ide/stores/actions/merge_request_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; -import store from '~/ide/stores'; -import createFlash from '~/flash'; +import { createStore } from '~/ide/stores'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { getMergeRequestData, getMergeRequestChanges, @@ -10,7 +10,6 @@ import { } from '~/ide/stores/actions/merge_request'; import service from '~/ide/services'; import { leftSidebarViews, PERMISSION_READ_MR } from '~/ide/constants'; -import { resetStore } from '../../helpers'; const TEST_PROJECT = 'abcproject'; const TEST_PROJECT_ID = 17; @@ -18,9 +17,12 @@ const TEST_PROJECT_ID = 17; jest.mock('~/flash'); describe('IDE store merge request actions', () => { + let store; let mock; beforeEach(() => { + store = createStore(); + mock = new MockAdapter(axios); store.state.projects[TEST_PROJECT] = { @@ -34,7 +36,6 @@ describe('IDE store merge request actions', () => { afterEach(() => { mock.restore(); - resetStore(store); }); describe('getMergeRequestsForBranch', () => { diff --git a/spec/frontend/ide/stores/actions/project_spec.js b/spec/frontend/ide/stores/actions/project_spec.js index 64024c12903..ca3687307a9 100644 --- a/spec/frontend/ide/stores/actions/project_spec.js +++ b/spec/frontend/ide/stores/actions/project_spec.js @@ -1,4 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import axios from '~/lib/utils/axios_utils'; import { createStore } from '~/ide/stores'; import { @@ -12,8 +14,6 @@ import { } from '~/ide/stores/actions'; import service from '~/ide/services'; import api from '~/api'; -import testAction from 'helpers/vuex_action_helper'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; const TEST_PROJECT_ID = 'abc/def'; diff --git a/spec/frontend/ide/stores/actions/tree_spec.js b/spec/frontend/ide/stores/actions/tree_spec.js index c20941843c4..0eabd982d57 100644 --- a/spec/frontend/ide/stores/actions/tree_spec.js +++ b/spec/frontend/ide/stores/actions/tree_spec.js @@ -1,5 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import { showTreeEntry, getFiles, setDirectoryData } from '~/ide/stores/actions/tree'; import * as types from '~/ide/stores/mutation_types'; import axios from '~/lib/utils/axios_utils'; @@ -7,7 +8,6 @@ import { createStore } from '~/ide/stores'; import service from '~/ide/services'; import { createRouter } from '~/ide/ide_router'; import { file, createEntriesFromPaths } from '../../helpers'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Multi-file store tree actions', () => { let projectTree; diff --git a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js index 5855496a330..c9676b23fa1 100644 --- a/spec/frontend/ide/stores/modules/file_templates/getters_spec.js +++ b/spec/frontend/ide/stores/modules/file_templates/getters_spec.js @@ -5,7 +5,7 @@ import * as getters from '~/ide/stores/modules/file_templates/getters'; describe('IDE file templates getters', () => { describe('templateTypes', () => { it('returns list of template types', () => { - expect(getters.templateTypes().length).toBe(4); + expect(getters.templateTypes().length).toBe(5); }); }); diff --git a/spec/frontend/ide/stores/modules/router/actions_spec.js b/spec/frontend/ide/stores/modules/router/actions_spec.js index 4795eae2b79..1458a43da57 100644 --- a/spec/frontend/ide/stores/modules/router/actions_spec.js +++ b/spec/frontend/ide/stores/modules/router/actions_spec.js @@ -1,6 +1,6 @@ +import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/ide/stores/modules/router/actions'; import * as types from '~/ide/stores/modules/router/mutation_types'; -import testAction from 'helpers/vuex_action_helper'; const TEST_PATH = 'test/path/abc'; diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js index 4bc937b4784..d0ac2af3ffd 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_controls_spec.js @@ -6,7 +6,7 @@ import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; import * as actions from '~/ide/stores/modules/terminal/actions/session_controls'; import httpStatus from '~/lib/utils/http_status'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js index 7909f828124..e25746e1dd1 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/session_status_spec.js @@ -5,7 +5,7 @@ import * as messages from '~/ide/stores/modules/terminal/messages'; import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; import * as actions from '~/ide/stores/modules/terminal/actions/session_status'; import axios from '~/lib/utils/axios_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); diff --git a/spec/frontend/ide/stores/modules/terminal/messages_spec.js b/spec/frontend/ide/stores/modules/terminal/messages_spec.js index 966158999da..1bb92a9dfa5 100644 --- a/spec/frontend/ide/stores/modules/terminal/messages_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/messages_spec.js @@ -15,6 +15,8 @@ describe('IDE store terminal messages', () => { sprintf( messages.ERROR_CONFIG, { + codeStart: `<code>`, + codeEnd: `</code>`, helpStart: `<a href="${escape(TEST_HELP_URL)}" target="_blank">`, helpEnd: '</a>', }, diff --git a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js index ac976300ed0..3fa57bde415 100644 --- a/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js +++ b/spec/frontend/ide/stores/modules/terminal_sync/actions_spec.js @@ -1,7 +1,7 @@ +import testAction from 'helpers/vuex_action_helper'; import * as actions from '~/ide/stores/modules/terminal_sync/actions'; import mirror, { canConnect, SERVICE_NAME } from '~/ide/lib/mirror'; import * as types from '~/ide/stores/modules/terminal_sync/mutation_types'; -import testAction from 'helpers/vuex_action_helper'; jest.mock('~/ide/lib/mirror'); diff --git a/spec/frontend/ide/sync_router_and_store_spec.js b/spec/frontend/ide/sync_router_and_store_spec.js index c4ce92b99cc..ccf6e200806 100644 --- a/spec/frontend/ide/sync_router_and_store_spec.js +++ b/spec/frontend/ide/sync_router_and_store_spec.js @@ -1,7 +1,7 @@ import VueRouter from 'vue-router'; +import waitForPromises from 'helpers/wait_for_promises'; import { createStore } from '~/ide/stores'; import { syncRouterAndStore } from '~/ide/sync_router_and_store'; -import waitForPromises from 'helpers/wait_for_promises'; const TEST_ROUTE = '/test/lorem/ipsum'; diff --git a/spec/frontend/ide/utils_spec.js b/spec/frontend/ide/utils_spec.js index b6de576a0a4..e7ef0de45a0 100644 --- a/spec/frontend/ide/utils_spec.js +++ b/spec/frontend/ide/utils_spec.js @@ -1,3 +1,4 @@ +import { languages } from 'monaco-editor'; import { isTextFile, registerLanguages, @@ -9,7 +10,6 @@ import { getPathParent, readFileAsDataURL, } from '~/ide/utils'; -import { languages } from 'monaco-editor'; describe('WebIDE utils', () => { describe('isTextFile', () => { diff --git a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js index 2deb4be2b91..98c05d648b8 100644 --- a/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js +++ b/spec/frontend/image_diff/helpers/comment_indicator_helper_spec.js @@ -1,6 +1,6 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; import * as commentIndicatorHelper from '~/image_diff/helpers/comment_indicator_helper'; import * as mockData from '../mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('commentIndicatorHelper', () => { const { coordinate } = mockData; diff --git a/spec/frontend/image_diff/helpers/utils_helper_spec.js b/spec/frontend/image_diff/helpers/utils_helper_spec.js index a47c681e775..7f2376826c2 100644 --- a/spec/frontend/image_diff/helpers/utils_helper_spec.js +++ b/spec/frontend/image_diff/helpers/utils_helper_spec.js @@ -1,7 +1,7 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; import * as utilsHelper from '~/image_diff/helpers/utils_helper'; import ImageBadge from '~/image_diff/image_badge'; import * as mockData from '../mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('utilsHelper', () => { const { noteId, discussionId, image, imageProperties, imageMeta } = mockData; diff --git a/spec/frontend/image_diff/image_diff_spec.js b/spec/frontend/image_diff/image_diff_spec.js index 2b29a522193..d89e4312344 100644 --- a/spec/frontend/image_diff/image_diff_spec.js +++ b/spec/frontend/image_diff/image_diff_spec.js @@ -1,8 +1,8 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; import ImageDiff from '~/image_diff/image_diff'; import * as imageUtility from '~/lib/utils/image_utility'; import imageDiffHelper from '~/image_diff/helpers/index'; import * as mockData from './mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('ImageDiff', () => { let element; diff --git a/spec/frontend/image_diff/replaced_image_diff_spec.js b/spec/frontend/image_diff/replaced_image_diff_spec.js index 38a43bfa858..10827d76e55 100644 --- a/spec/frontend/image_diff/replaced_image_diff_spec.js +++ b/spec/frontend/image_diff/replaced_image_diff_spec.js @@ -1,8 +1,8 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; import ReplacedImageDiff from '~/image_diff/replaced_image_diff'; import ImageDiff from '~/image_diff/image_diff'; import { viewTypes } from '~/image_diff/view_types'; import imageDiffHelper from '~/image_diff/helpers/index'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('ReplacedImageDiff', () => { let element; 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 419d67e239f..b217242968a 100644 --- a/spec/frontend/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_projects/components/import_projects_table_spec.js @@ -2,16 +2,14 @@ import { nextTick } from 'vue'; import Vuex from 'vuex'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlButton } from '@gitlab/ui'; -import { state, getters } from '~/import_projects/store'; -import eventHub from '~/import_projects/event_hub'; +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'; - -jest.mock('~/import_projects/event_hub', () => ({ - $emit: jest.fn(), -})); +import PageQueryParamSync from '~/import_projects/components/page_query_param_sync.vue'; describe('ImportProjectsTable', () => { let wrapper; @@ -21,13 +19,6 @@ describe('ImportProjectsTable', () => { const providerTitle = 'THE PROVIDER'; const providerRepo = { id: 10, sanitizedName: 'sanitizedName', fullName: 'fullName' }; - const importedProject = { - id: 1, - fullPath: 'fullPath', - importStatus: 'started', - providerLink: 'providerLink', - importSource: 'importSource', - }; const findImportAllButton = () => wrapper @@ -35,11 +26,15 @@ describe('ImportProjectsTable', () => { .filter(w => w.props().variant === 'success') .at(0); + const importAllFn = jest.fn(); + const setPageFn = jest.fn(); + function createComponent({ state: initialState, getters: customGetters, slots, filterable, + paginatable, } = {}) { const localVue = createLocalVue(); localVue.use(Vuex); @@ -52,11 +47,13 @@ describe('ImportProjectsTable', () => { }, actions: { fetchRepos: jest.fn(), - fetchReposFiltered: jest.fn(), fetchJobs: jest.fn(), + fetchNamespaces: jest.fn(), + importAll: importAllFn, stopJobsPolling: jest.fn(), clearJobsEtagPoll: jest.fn(), setFilter: jest.fn(), + setPage: setPageFn, }, }); @@ -66,6 +63,7 @@ describe('ImportProjectsTable', () => { propsData: { providerTitle, filterable, + paginatable, }, slots, }); @@ -79,11 +77,13 @@ describe('ImportProjectsTable', () => { }); it('renders a loading icon while repos are loading', () => { - createComponent({ - state: { - isLoadingRepos: true, - }, - }); + createComponent({ state: { isLoadingRepos: true } }); + + expect(wrapper.contains(GlLoadingIcon)).toBe(true); + }); + + it('renders a loading icon while namespaces are loading', () => { + createComponent({ state: { isLoadingNamespaces: true } }); expect(wrapper.contains(GlLoadingIcon)).toBe(true); }); @@ -91,10 +91,16 @@ describe('ImportProjectsTable', () => { it('renders a table with imported projects and provider repos', () => { createComponent({ state: { - importedProjects: [importedProject], - providerRepos: [providerRepo], - incompatibleRepos: [{ ...providerRepo, id: 11 }], - namespaces: [{ path: 'path' }], + 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, + }, + ], }, }); @@ -133,13 +139,7 @@ describe('ImportProjectsTable', () => { ); it('renders an empty state if there are no projects available', () => { - createComponent({ - state: { - importedProjects: [], - providerRepos: [], - incompatibleProjects: [], - }, - }); + createComponent({ state: { repositories: [] } }); expect(wrapper.contains(ProviderRepoTableRow)).toBe(false); expect(wrapper.contains(ImportedProjectTableRow)).toBe(false); @@ -147,37 +147,63 @@ describe('ImportProjectsTable', () => { }); it('sends importAll event when import button is clicked', async () => { - createComponent({ - state: { - providerRepos: [providerRepo], - }, - }); + createComponent({ state: { providerRepos: [providerRepo] } }); findImportAllButton().vm.$emit('click'); await nextTick(); - expect(eventHub.$emit).toHaveBeenCalledWith('importAll'); + + expect(importAllFn).toHaveBeenCalled(); }); it('shows loading spinner when import is in progress', () => { - createComponent({ - getters: { - isImportingAnyRepo: () => true, - }, - }); + createComponent({ getters: { isImportingAnyRepo: () => true } }); expect(findImportAllButton().props().loading).toBe(true); }); it('renders filtering input field by default', () => { createComponent(); + expect(findFilterField().exists()).toBe(true); }); it('does not render filtering input field when filterable is false', () => { createComponent({ filterable: false }); + expect(findFilterField().exists()).toBe(false); }); + describe('when paginatable is set to true', () => { + const pageInfo = { page: 1 }; + + beforeEach(() => { + createComponent({ + state: { + namespaces: [{ fullPath: 'path' }], + pageInfo, + repositories: [ + { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, + ], + }, + paginatable: true, + }); + }); + + it('passes current page to page-query-param-sync component', () => { + expect(wrapper.find(PageQueryParamSync).props().page).toBe(pageInfo.page); + }); + + it('dispatches setPage when page-query-param-sync emits popstate', () => { + const NEW_PAGE = 2; + wrapper.find(PageQueryParamSync).vm.$emit('popstate', NEW_PAGE); + + const { calls } = setPageFn.mock; + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(NEW_PAGE); + }); + }); + 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 index 700dd1e025a..8890c352826 100644 --- a/spec/frontend/import_projects/components/imported_project_table_row_spec.js +++ b/spec/frontend/import_projects/components/imported_project_table_row_spec.js @@ -1,57 +1,44 @@ -import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import createStore from '~/import_projects/store'; -import importedProjectTableRow from '~/import_projects/components/imported_project_table_row.vue'; -import STATUS_MAP from '~/import_projects/constants'; +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 vm; + let wrapper; const project = { - id: 1, - fullPath: 'fullPath', - importStatus: 'finished', - providerLink: 'providerLink', - importSource: 'importSource', + importSource: { + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + }, + importStatus: STATUSES.FINISHED, }; function mountComponent() { - const localVue = createLocalVue(); - localVue.use(Vuex); - - const component = mount(importedProjectTableRow, { - localVue, - store: createStore(), - propsData: { - project: { - ...project, - }, - }, - }); - - return component.vm; + wrapper = mount(ImportedProjectTableRow, { propsData: { project } }); } beforeEach(() => { - vm = mountComponent(); + mountComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders an imported project table row', () => { - const providerLink = vm.$el.querySelector('.js-provider-link'); - const statusObject = STATUS_MAP[project.importStatus]; - - expect(vm.$el.classList.contains('js-imported-project')).toBe(true); - expect(providerLink.href).toMatch(project.providerLink); - expect(providerLink.textContent).toMatch(project.importSource); - expect(vm.$el.querySelector('.js-full-path').textContent).toMatch(project.fullPath); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, + 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, ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - expect(vm.$el.querySelector('.js-go-to-project').href).toMatch(project.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 new file mode 100644 index 00000000000..be19ecca1ba --- /dev/null +++ b/spec/frontend/import_projects/components/page_query_param_sync_spec.js @@ -0,0 +1,87 @@ +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 f5e5141eac8..bd9cd07db78 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,100 +1,100 @@ +import { nextTick } from 'vue'; import Vuex from 'vuex'; -import { createLocalVue, mount } from '@vue/test-utils'; -import { state, actions, getters, mutations } from '~/import_projects/store'; -import providerRepoTableRow from '~/import_projects/components/provider_repo_table_row.vue'; -import STATUS_MAP, { STATUSES } from '~/import_projects/constants'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +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'; +import Select2Select from '~/vue_shared/components/select2_select.vue'; describe('ProviderRepoTableRow', () => { - let vm; + let wrapper; const fetchImport = jest.fn(); - const importPath = '/import-path'; - const defaultTargetNamespace = 'user'; - const ciCdOnly = true; + const setImportTarget = jest.fn(); + const fakeImportTarget = { + targetNamespace: 'target', + newName: 'newName', + }; + const ciCdOnly = false; const repo = { - id: 10, - sanitizedName: 'sanitizedName', - fullName: 'fullName', - providerLink: 'providerLink', + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + }, + importStatus: STATUSES.FINISHED, }; - function initStore(initialState) { - const stubbedActions = { ...actions, fetchImport }; + const availableNamespaces = [ + { text: 'Groups', children: [{ id: 'test', text: 'test' }] }, + { text: 'Users', children: [{ id: 'root', text: 'root' }] }, + ]; + function initStore(initialState) { const store = new Vuex.Store({ - state: { ...state(), ...initialState }, - actions: stubbedActions, - mutations, - getters, + state: initialState, + getters: { + getImportTarget: () => () => fakeImportTarget, + }, + actions: { fetchImport, setImportTarget }, }); return store; } + const findImportButton = () => + wrapper + .findAll('button') + .filter(node => node.text() === 'Import') + .at(0); + function mountComponent(initialState) { const localVue = createLocalVue(); localVue.use(Vuex); - const store = initStore({ importPath, defaultTargetNamespace, ciCdOnly, ...initialState }); + const store = initStore({ ciCdOnly, ...initialState }); - const component = mount(providerRepoTableRow, { + wrapper = shallowMount(ProviderRepoTableRow, { localVue, store, - propsData: { - repo, - }, + propsData: { repo, availableNamespaces }, }); - - return component.vm; } beforeEach(() => { - vm = mountComponent(); + mountComponent(); }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); }); it('renders a provider repo table row', () => { - const providerLink = vm.$el.querySelector('.js-provider-link'); - const statusObject = STATUS_MAP[STATUSES.NONE]; - - expect(vm.$el.classList.contains('js-provider-repo')).toBe(true); - expect(providerLink.href).toMatch(repo.providerLink); - expect(providerLink.textContent).toMatch(repo.fullName); - expect(vm.$el.querySelector(`.${statusObject.textClass}`).textContent).toMatch( - statusObject.text, - ); - - expect(vm.$el.querySelector(`.ic-status_${statusObject.icon}`)).not.toBeNull(); - expect(vm.$el.querySelector('.js-import-button')).not.toBeNull(); + const providerLink = wrapper.find('[data-testid=providerLink]'); + + 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); }); it('renders a select2 namespace select', () => { - const dropdownTrigger = vm.$el.querySelector('.js-namespace-select'); - - expect(dropdownTrigger).not.toBeNull(); - expect(dropdownTrigger.classList.contains('select2-container')).toBe(true); - - dropdownTrigger.click(); - - expect(vm.$el.querySelector('.select2-drop')).not.toBeNull(); + expect(wrapper.contains(Select2Select)).toBe(true); + expect(wrapper.find(Select2Select).props().options.data).toBe(availableNamespaces); }); - it('imports repo when clicking import button', () => { - vm.$el.querySelector('.js-import-button').click(); + it('imports repo when clicking import button', async () => { + findImportButton().trigger('click'); - return vm.$nextTick().then(() => { - const { calls } = fetchImport.mock; + await nextTick(); - // Not using .toBeCalledWith because it expects - // an unmatchable and undefined 3rd argument. - expect(calls.length).toBe(1); - expect(calls[0][1]).toEqual({ - repo, - newName: repo.sanitizedName, - targetNamespace: defaultTargetNamespace, - }); - }); + const { calls } = fetchImport.mock; + + expect(calls).toHaveLength(1); + expect(calls[0][1]).toBe(repo.importSource.id); }); }); diff --git a/spec/frontend/import_projects/store/actions_spec.js b/spec/frontend/import_projects/store/actions_spec.js index fd6fbcbfce0..45a59b3f6d6 100644 --- a/spec/frontend/import_projects/store/actions_spec.js +++ b/spec/frontend/import_projects/store/actions_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; -import createFlash from '~/flash'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { @@ -12,41 +12,79 @@ import { RECEIVE_IMPORT_SUCCESS, RECEIVE_IMPORT_ERROR, RECEIVE_JOBS_SUCCESS, + REQUEST_NAMESPACES, + RECEIVE_NAMESPACES_SUCCESS, + RECEIVE_NAMESPACES_ERROR, + SET_PAGE, } from '~/import_projects/store/mutation_types'; -import { - fetchRepos, - fetchImport, - receiveJobsSuccess, - fetchJobs, - clearJobsEtagPoll, - stopJobsPolling, -} from '~/import_projects/store/actions'; +import actionsFactory from '~/import_projects/store/actions'; +import { getImportTarget } from '~/import_projects/store/getters'; import state from '~/import_projects/store/state'; +import { STATUSES } from '~/import_projects/constants'; jest.mock('~/flash'); +const MOCK_ENDPOINT = `${TEST_HOST}/endpoint.json`; +const endpoints = { + reposPath: MOCK_ENDPOINT, + importPath: MOCK_ENDPOINT, + jobsPath: MOCK_ENDPOINT, + namespacesPath: MOCK_ENDPOINT, +}; + +const { + clearJobsEtagPoll, + stopJobsPolling, + importAll, + fetchRepos, + fetchImport, + fetchJobs, + fetchNamespaces, + setPage, +} = actionsFactory({ + endpoints, +}); + describe('import_projects store actions', () => { let localState; - const repos = [{ id: 1 }, { id: 2 }]; - const importPayload = { newName: 'newName', targetNamespace: 'targetNamespace', repo: { id: 1 } }; + const importRepoId = 1; + const otherImportRepoId = 2; + const defaultTargetNamespace = 'default'; + const sanitizedName = 'sanitizedName'; + const defaultImportTarget = { newName: sanitizedName, targetNamespace: defaultTargetNamespace }; beforeEach(() => { - localState = state(); + localState = { + ...state(), + defaultTargetNamespace, + repositories: [ + { importSource: { id: importRepoId, sanitizedName }, importStatus: STATUSES.NONE }, + { + importSource: { id: otherImportRepoId, sanitizedName: 's2' }, + importStatus: STATUSES.NONE, + }, + { + importSource: { id: 3, sanitizedName: 's3', incompatible: true }, + importStatus: STATUSES.NONE, + }, + ], + }; + + localState.getImportTarget = getImportTarget(localState); }); describe('fetchRepos', () => { let mock; - const payload = { imported_projects: [{}], provider_repos: [{}], namespaces: [{}] }; + const payload = { imported_projects: [{}], provider_repos: [{}] }; beforeEach(() => { - localState.reposPath = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); afterEach(() => mock.restore()); it('dispatches stopJobsPolling actions and commits REQUEST_REPOS, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, payload); + mock.onGet(MOCK_ENDPOINT).reply(200, payload); return testAction( fetchRepos, @@ -64,7 +102,7 @@ describe('import_projects store actions', () => { }); it('dispatches stopJobsPolling action and commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(500); + mock.onGet(MOCK_ENDPOINT).reply(500); return testAction( fetchRepos, @@ -75,18 +113,39 @@ describe('import_projects store actions', () => { ); }); - describe('when filtered', () => { - beforeEach(() => { - localState.filter = 'filter'; + describe('when pagination is enabled', () => { + it('includes page in url query params', async () => { + const { fetchRepos: fetchReposWithPagination } = actionsFactory({ + endpoints, + hasPagination: true, + }); + + let requestedUrl; + mock.onGet().reply(config => { + requestedUrl = config.url; + return [200, payload]; + }); + + await testAction( + fetchReposWithPagination, + null, + localState, + expect.any(Array), + expect.any(Array), + ); + + expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localState.pageInfo.page}`); }); + }); + describe('when filtered', () => { it('fetches repos with filter applied', () => { mock.onGet(`${TEST_HOST}/endpoint.json?filter=filter`).reply(200, payload); return testAction( fetchRepos, null, - localState, + { ...localState, filter: 'filter' }, [ { type: REQUEST_REPOS }, { @@ -104,7 +163,6 @@ describe('import_projects store actions', () => { let mock; beforeEach(() => { - localState.importPath = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); @@ -112,15 +170,17 @@ describe('import_projects store actions', () => { it('commits REQUEST_IMPORT and REQUEST_IMPORT_SUCCESS mutations on a successful request', () => { const importedProject = { name: 'imported/project' }; - const importRepoId = importPayload.repo.id; - mock.onPost(`${TEST_HOST}/endpoint.json`).reply(200, importedProject); + mock.onPost(MOCK_ENDPOINT).reply(200, importedProject); return testAction( fetchImport, - importPayload, + importRepoId, localState, [ - { type: REQUEST_IMPORT, payload: importRepoId }, + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, { type: RECEIVE_IMPORT_SUCCESS, payload: { @@ -134,15 +194,18 @@ describe('import_projects store actions', () => { }); it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows generic error message on an unsuccessful request', async () => { - mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500); + mock.onPost(MOCK_ENDPOINT).reply(500); await testAction( fetchImport, - importPayload, + importRepoId, localState, [ - { type: REQUEST_IMPORT, payload: importPayload.repo.id }, - { type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id }, + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { type: RECEIVE_IMPORT_ERROR, payload: importRepoId }, ], [], ); @@ -152,15 +215,18 @@ describe('import_projects store actions', () => { it('commits REQUEST_IMPORT and RECEIVE_IMPORT_ERROR and shows detailed error message on an unsuccessful request with errors fields in response', async () => { const ERROR_MESSAGE = 'dummy'; - mock.onPost(`${TEST_HOST}/endpoint.json`).reply(500, { errors: ERROR_MESSAGE }); + mock.onPost(MOCK_ENDPOINT).reply(500, { errors: ERROR_MESSAGE }); await testAction( fetchImport, - importPayload, + importRepoId, localState, [ - { type: REQUEST_IMPORT, payload: importPayload.repo.id }, - { type: RECEIVE_IMPORT_ERROR, payload: importPayload.repo.id }, + { + type: REQUEST_IMPORT, + payload: { repoId: importRepoId, importTarget: defaultImportTarget }, + }, + { type: RECEIVE_IMPORT_ERROR, payload: importRepoId }, ], [], ); @@ -169,24 +235,11 @@ describe('import_projects store actions', () => { }); }); - describe('receiveJobsSuccess', () => { - it(`commits ${RECEIVE_JOBS_SUCCESS} mutation`, () => { - return testAction( - receiveJobsSuccess, - repos, - localState, - [{ type: RECEIVE_JOBS_SUCCESS, payload: repos }], - [], - ); - }); - }); - describe('fetchJobs', () => { let mock; const updatedProjects = [{ name: 'imported/project' }, { name: 'provider/repo' }]; beforeEach(() => { - localState.jobsPath = `${TEST_HOST}/endpoint.json`; mock = new MockAdapter(axios); }); @@ -198,7 +251,7 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); it('commits RECEIVE_JOBS_SUCCESS mutation on a successful request', async () => { - mock.onGet(`${TEST_HOST}/endpoint.json`).reply(200, updatedProjects); + mock.onGet(MOCK_ENDPOINT).reply(200, updatedProjects); await testAction( fetchJobs, @@ -237,4 +290,78 @@ describe('import_projects store actions', () => { }); }); }); + + describe('fetchNamespaces', () => { + let mock; + const namespaces = [{ full_name: 'test/ns1' }, { full_name: 'test_ns2' }]; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => mock.restore()); + + it('commits REQUEST_NAMESPACES and RECEIVE_NAMESPACES_SUCCESS on success', async () => { + mock.onGet(MOCK_ENDPOINT).reply(200, namespaces); + + await testAction( + fetchNamespaces, + null, + localState, + [ + { type: REQUEST_NAMESPACES }, + { + type: RECEIVE_NAMESPACES_SUCCESS, + payload: convertObjectPropsToCamelCase(namespaces, { deep: true }), + }, + ], + [], + ); + }); + + it('commits REQUEST_NAMESPACES and RECEIVE_NAMESPACES_ERROR and shows generic error message on an unsuccessful request', async () => { + mock.onGet(MOCK_ENDPOINT).reply(500); + + await testAction( + fetchNamespaces, + null, + localState, + [{ type: REQUEST_NAMESPACES }, { type: RECEIVE_NAMESPACES_ERROR }], + [], + ); + + expect(createFlash).toHaveBeenCalledWith('Requesting namespaces failed'); + }); + }); + + describe('importAll', () => { + it('dispatches multiple fetchImport actions', async () => { + await testAction( + importAll, + null, + localState, + [], + [ + { type: 'fetchImport', payload: importRepoId }, + { type: 'fetchImport', payload: otherImportRepoId }, + ], + ); + }); + + describe('setPage', () => { + it('dispatches fetchRepos and commits setPage when page number differs from current one', async () => { + await testAction( + setPage, + 2, + { ...localState, pageInfo: { page: 1 } }, + [{ type: SET_PAGE, payload: 2 }], + [{ type: 'fetchRepos' }], + ); + }); + + it('does not perform any action if page equals to current one', async () => { + await testAction(setPage, 2, { ...localState, pageInfo: { page: 2 } }, [], []); + }); + }); + }); }); diff --git a/spec/frontend/import_projects/store/getters_spec.js b/spec/frontend/import_projects/store/getters_spec.js index 93d1ed89783..5c1ea25a684 100644 --- a/spec/frontend/import_projects/store/getters_spec.js +++ b/spec/frontend/import_projects/store/getters_spec.js @@ -1,12 +1,28 @@ import { - namespaceSelectOptions, + isLoading, isImportingAnyRepo, - hasProviderRepos, hasIncompatibleRepos, - hasImportedProjects, + hasImportableRepos, + getImportTarget, } from '~/import_projects/store/getters'; +import { STATUSES } from '~/import_projects/constants'; import state from '~/import_projects/store/state'; +const IMPORTED_REPO = { + importSource: {}, + importedProject: { fullPath: 'some/path' }, +}; + +const IMPORTABLE_REPO = { + importSource: { id: 'some-id', sanitizedName: 'sanitized' }, + importedProject: null, + importStatus: STATUSES.NONE, +}; + +const INCOMPATIBLE_REPO = { + importSource: { incompatible: true }, +}; + describe('import_projects store getters', () => { let localState; @@ -14,85 +30,87 @@ describe('import_projects store getters', () => { localState = state(); }); - describe('namespaceSelectOptions', () => { - const namespaces = [{ fullPath: 'namespace-0' }, { fullPath: 'namespace-1' }]; - const defaultTargetNamespace = 'current-user'; - - it('returns an options array with a "Users" and "Groups" optgroups', () => { - localState.namespaces = namespaces; - localState.defaultTargetNamespace = defaultTargetNamespace; - - const optionsArray = namespaceSelectOptions(localState); - const groupsGroup = optionsArray[0]; - const usersGroup = optionsArray[1]; - - expect(groupsGroup.text).toBe('Groups'); - expect(usersGroup.text).toBe('Users'); - - groupsGroup.children.forEach((child, index) => { - expect(child.id).toBe(namespaces[index].fullPath); - expect(child.text).toBe(namespaces[index].fullPath); + it.each` + isLoadingRepos | isLoadingNamespaces | isLoadingValue + ${false} | ${false} | ${false} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + ${true} | ${true} | ${true} + `( + 'isLoading returns $isLoadingValue when isLoadingRepos is $isLoadingRepos and isLoadingNamespaces is $isLoadingNamespaces', + ({ isLoadingRepos, isLoadingNamespaces, isLoadingValue }) => { + Object.assign(localState, { + isLoadingRepos, + isLoadingNamespaces, }); - expect(usersGroup.children.length).toBe(1); - expect(usersGroup.children[0].id).toBe(defaultTargetNamespace); - expect(usersGroup.children[0].text).toBe(defaultTargetNamespace); - }); - }); - - describe('isImportingAnyRepo', () => { - it('returns true if there are any reposBeingImported', () => { - localState.reposBeingImported = new Array(1); - - expect(isImportingAnyRepo(localState)).toBe(true); - }); + expect(isLoading(localState)).toBe(isLoadingValue); + }, + ); + + it.each` + importStatus | value + ${STATUSES.NONE} | ${false} + ${STATUSES.SCHEDULING} | ${true} + ${STATUSES.SCHEDULED} | ${true} + ${STATUSES.STARTED} | ${true} + ${STATUSES.FINISHED} | ${false} + `( + 'isImportingAnyRepo returns $value when repo with $importStatus status is available', + ({ importStatus, value }) => { + localState.repositories = [{ importStatus }]; + + expect(isImportingAnyRepo(localState)).toBe(value); + }, + ); - it('returns false if there are no reposBeingImported', () => { - localState.reposBeingImported = []; - - expect(isImportingAnyRepo(localState)).toBe(false); - }); - }); - - describe('hasProviderRepos', () => { - it('returns true if there are any providerRepos', () => { - localState.providerRepos = new Array(1); + describe('hasIncompatibleRepos', () => { + it('returns true if there are any incompatible projects', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; - expect(hasProviderRepos(localState)).toBe(true); + expect(hasIncompatibleRepos(localState)).toBe(true); }); - it('returns false if there are no providerRepos', () => { - localState.providerRepos = []; + it('returns false if there are no incompatible projects', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO]; - expect(hasProviderRepos(localState)).toBe(false); + expect(hasIncompatibleRepos(localState)).toBe(false); }); }); - describe('hasImportedProjects', () => { - it('returns true if there are any importedProjects', () => { - localState.importedProjects = new Array(1); + describe('hasImportableRepos', () => { + it('returns true if there are any importable projects ', () => { + localState.repositories = [IMPORTABLE_REPO, IMPORTED_REPO, INCOMPATIBLE_REPO]; - expect(hasImportedProjects(localState)).toBe(true); + expect(hasImportableRepos(localState)).toBe(true); }); - it('returns false if there are no importedProjects', () => { - localState.importedProjects = []; + it('returns false if there are no importable projects', () => { + localState.repositories = [IMPORTED_REPO, INCOMPATIBLE_REPO]; - expect(hasImportedProjects(localState)).toBe(false); + expect(hasImportableRepos(localState)).toBe(false); }); }); - describe('hasIncompatibleRepos', () => { - it('returns true if there are any incompatibleProjects', () => { - localState.incompatibleRepos = new Array(1); + describe('getImportTarget', () => { + it('returns default value if no custom target available', () => { + localState.defaultTargetNamespace = 'default'; + localState.repositories = [IMPORTABLE_REPO]; - expect(hasIncompatibleRepos(localState)).toBe(true); + expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual({ + newName: IMPORTABLE_REPO.importSource.sanitizedName, + targetNamespace: localState.defaultTargetNamespace, + }); }); - it('returns false if there are no incompatibleProjects', () => { - localState.incompatibleRepos = []; + it('returns custom import target if available', () => { + const fakeTarget = { newName: 'something', targetNamespace: 'ns' }; + localState.repositories = [IMPORTABLE_REPO]; + localState.customImportTargets[IMPORTABLE_REPO.importSource.id] = fakeTarget; - expect(hasIncompatibleRepos(localState)).toBe(false); + expect(getImportTarget(localState)(IMPORTABLE_REPO.importSource.id)).toStrictEqual( + fakeTarget, + ); }); }); }); diff --git a/spec/frontend/import_projects/store/mutations_spec.js b/spec/frontend/import_projects/store/mutations_spec.js index 505545f7aa5..3672ec9f2c0 100644 --- a/spec/frontend/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_projects/store/mutations_spec.js @@ -1,34 +1,303 @@ import * as types from '~/import_projects/store/mutation_types'; import mutations from '~/import_projects/store/mutations'; +import { STATUSES } from '~/import_projects/constants'; describe('import_projects store mutations', () => { - describe(`${types.RECEIVE_IMPORT_SUCCESS}`, () => { - it('removes repoId from reposBeingImported and providerRepos, adds to importedProjects', () => { - const repoId = 1; - const state = { - reposBeingImported: [repoId], - providerRepos: [{ id: repoId }], + let state; + const SOURCE_PROJECT = { + id: 1, + full_name: 'full/name', + sanitized_name: 'name', + provider_link: 'https://demo.link/full/name', + }; + const IMPORTED_PROJECT = { + name: 'demo', + importSource: 'something', + providerLink: 'custom-link', + importStatus: 'status', + fullName: 'fullName', + }; + + describe(`${types.SET_FILTER}`, () => { + it('overwrites current filter value', () => { + state = { filter: 'some-value' }; + const NEW_VALUE = 'new-value'; + + mutations[types.SET_FILTER](state, NEW_VALUE); + + expect(state.filter).toBe(NEW_VALUE); + }); + }); + + describe(`${types.REQUEST_REPOS}`, () => { + it('sets repos loading flag to true', () => { + state = {}; + + mutations[types.REQUEST_REPOS](state); + + expect(state.isLoadingRepos).toBe(true); + }); + }); + + describe(`${types.RECEIVE_REPOS_SUCCESS}`, () => { + describe('for imported projects', () => { + const response = { + importedProjects: [IMPORTED_PROJECT], + providerRepos: [], + }; + + it('picks import status from response', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus); + }); + + it('recreates importSource from response', () => { + state = {}; + + 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, + }), + ); + }); + + it('passes project to importProject', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(IMPORTED_PROJECT).toStrictEqual( + expect.objectContaining(state.repositories[0].importedProject), + ); + }); + }); + + describe('for importable projects', () => { + beforeEach(() => { + state = {}; + const response = { + importedProjects: [], + providerRepos: [SOURCE_PROJECT], + }; + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + }); + + it('sets import status to none', () => { + expect(state.repositories[0].importStatus).toBe(STATUSES.NONE); + }); + + it('sets importSource to project', () => { + expect(state.repositories[0].importSource).toBe(SOURCE_PROJECT); + }); + }); + + describe('for incompatible projects', () => { + const response = { importedProjects: [], + providerRepos: [], + incompatibleRepos: [SOURCE_PROJECT], }; - const importedProject = { id: repoId }; - mutations[types.RECEIVE_IMPORT_SUCCESS](state, { importedProject, repoId }); + beforeEach(() => { + state = {}; + 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).toStrictEqual( + expect.objectContaining(SOURCE_PROJECT), + ); + }); + }); + + it('sets repos loading flag to false', () => { + const response = { + importedProjects: [], + providerRepos: [], + }; + state = {}; + + mutations[types.RECEIVE_REPOS_SUCCESS](state, response); + + expect(state.isLoadingRepos).toBe(false); + }); + }); + + describe(`${types.RECEIVE_REPOS_ERROR}`, () => { + it('sets repos loading flag to false', () => { + state = {}; + + mutations[types.RECEIVE_REPOS_ERROR](state); + + expect(state.isLoadingRepos).toBe(false); + }); + }); + + describe(`${types.REQUEST_IMPORT}`, () => { + beforeEach(() => { + const REPO_ID = 1; + const importTarget = { targetNamespace: 'ns', newName: 'name ' }; + state = { repositories: [{ importSource: { id: REPO_ID } }] }; + + mutations[types.REQUEST_IMPORT](state, { repoId: REPO_ID, importTarget }); + }); + + it(`sets status to ${STATUSES.SCHEDULING}`, () => { + expect(state.repositories[0].importStatus).toBe(STATUSES.SCHEDULING); + }); + }); + + describe(`${types.RECEIVE_IMPORT_SUCCESS}`, () => { + beforeEach(() => { + const REPO_ID = 1; + state = { repositories: [{ importSource: { id: REPO_ID } }] }; + + mutations[types.RECEIVE_IMPORT_SUCCESS](state, { + repoId: REPO_ID, + importedProject: IMPORTED_PROJECT, + }); + }); - expect(state.reposBeingImported.includes(repoId)).toBe(false); - expect(state.providerRepos.some(repo => repo.id === repoId)).toBe(false); - expect(state.importedProjects.some(repo => repo.id === repoId)).toBe(true); + it('sets import status', () => { + expect(state.repositories[0].importStatus).toBe(IMPORTED_PROJECT.importStatus); + }); + + it('sets imported project', () => { + expect(IMPORTED_PROJECT).toStrictEqual( + expect.objectContaining(state.repositories[0].importedProject), + ); + }); + }); + + describe(`${types.RECEIVE_IMPORT_ERROR}`, () => { + beforeEach(() => { + const REPO_ID = 1; + state = { repositories: [{ importSource: { id: REPO_ID } }] }; + + mutations[types.RECEIVE_IMPORT_ERROR](state, REPO_ID); + }); + + it(`resets import status to ${STATUSES.NONE}`, () => { + expect(state.repositories[0].importStatus).toBe(STATUSES.NONE); }); }); describe(`${types.RECEIVE_JOBS_SUCCESS}`, () => { - it('updates importStatus of existing importedProjects', () => { + it('updates import status of existing project', () => { const repoId = 1; - const state = { importedProjects: [{ id: repoId, importStatus: 'started' }] }; - const updatedProjects = [{ id: repoId, importStatus: 'finished' }]; + state = { + repositories: [{ importedProject: { id: repoId }, importStatus: STATUSES.STARTED }], + }; + const updatedProjects = [{ id: repoId, importStatus: STATUSES.FINISHED }]; mutations[types.RECEIVE_JOBS_SUCCESS](state, updatedProjects); - expect(state.importedProjects[0].importStatus).toBe(updatedProjects[0].importStatus); + expect(state.repositories[0].importStatus).toBe(updatedProjects[0].importStatus); + }); + }); + + describe(`${types.REQUEST_NAMESPACES}`, () => { + it('sets namespaces loading flag to true', () => { + state = {}; + + mutations[types.REQUEST_NAMESPACES](state); + + expect(state.isLoadingNamespaces).toBe(true); + }); + }); + + describe(`${types.RECEIVE_NAMESPACES_SUCCESS}`, () => { + const response = [{ fullPath: 'some/path' }]; + + beforeEach(() => { + state = {}; + mutations[types.RECEIVE_NAMESPACES_SUCCESS](state, response); + }); + + it('stores namespaces to state', () => { + expect(state.namespaces).toStrictEqual(response); + }); + + it('sets namespaces loading flag to false', () => { + expect(state.isLoadingNamespaces).toBe(false); + }); + }); + + describe(`${types.RECEIVE_NAMESPACES_ERROR}`, () => { + it('sets namespaces loading flag to false', () => { + state = {}; + + mutations[types.RECEIVE_NAMESPACES_ERROR](state); + + expect(state.isLoadingNamespaces).toBe(false); + }); + }); + + describe(`${types.SET_IMPORT_TARGET}`, () => { + const PROJECT = { + id: 2, + sanitizedName: 'sanitizedName', + }; + + it('stores custom target if it differs from defaults', () => { + state = { customImportTargets: {}, repositories: [{ importSource: PROJECT }] }; + const importTarget = { targetNamespace: 'ns', newName: 'name ' }; + + mutations[types.SET_IMPORT_TARGET](state, { repoId: PROJECT.id, importTarget }); + expect(state.customImportTargets[PROJECT.id]).toBe(importTarget); + }); + + it('removes custom target if it is equal to defaults', () => { + const importTarget = { targetNamespace: 'ns', newName: 'name ' }; + state = { + defaultTargetNamespace: 'default', + customImportTargets: { + [PROJECT.id]: importTarget, + }, + repositories: [{ importSource: PROJECT }], + }; + + mutations[types.SET_IMPORT_TARGET](state, { + repoId: PROJECT.id, + importTarget: { + targetNamespace: state.defaultTargetNamespace, + newName: PROJECT.sanitizedName, + }, + }); + + expect(state.customImportTargets[SOURCE_PROJECT.id]).toBeUndefined(); + }); + }); + + 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; + state = { pageInfo: { page: 5 } }; + + mutations[types.SET_PAGE](state, NEW_PAGE); + expect(state.pageInfo.page).toBe(NEW_PAGE); }); }); }); diff --git a/spec/frontend/import_projects/utils_spec.js b/spec/frontend/import_projects/utils_spec.js new file mode 100644 index 00000000000..826b06d5a70 --- /dev/null +++ b/spec/frontend/import_projects/utils_spec.js @@ -0,0 +1,32 @@ +import { isProjectImportable } from '~/import_projects/utils'; +import { STATUSES } from '~/import_projects/constants'; + +describe('import_projects utils', () => { + describe('isProjectImportable', () => { + it.each` + status | result + ${STATUSES.FINISHED} | ${false} + ${STATUSES.FAILED} | ${false} + ${STATUSES.SCHEDULED} | ${false} + ${STATUSES.STARTED} | ${false} + ${STATUSES.NONE} | ${true} + ${STATUSES.SCHEDULING} | ${false} + `('returns $result when project is compatible and status is $status', ({ status, result }) => { + expect( + isProjectImportable({ + importStatus: status, + importSource: { incompatible: false }, + }), + ).toBe(result); + }); + + it('returns false if project is not compatible', () => { + expect( + isProjectImportable({ + importStatus: STATUSES.NONE, + importSource: { incompatible: true }, + }), + ).toBe(false); + }); + }); +}); diff --git a/spec/frontend/incidents/components/incidents_list_spec.js b/spec/frontend/incidents/components/incidents_list_spec.js new file mode 100644 index 00000000000..33ddd06d6d9 --- /dev/null +++ b/spec/frontend/incidents/components/incidents_list_spec.js @@ -0,0 +1,362 @@ +import { mount } from '@vue/test-utils'; +import { + GlAlert, + GlLoadingIcon, + GlTable, + GlAvatar, + GlPagination, + GlSearchBoxByType, + GlTab, + GlTabs, + GlBadge, + GlEmptyState, +} from '@gitlab/ui'; +import { visitUrl, joinPaths, mergeUrlParams } from '~/lib/utils/url_utility'; +import IncidentsList from '~/incidents/components/incidents_list.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'; + +jest.mock('~/lib/utils/url_utility', () => ({ + visitUrl: jest.fn().mockName('visitUrlMock'), + joinPaths: jest.fn().mockName('joinPaths'), + mergeUrlParams: jest.fn().mockName('mergeUrlParams'), +})); + +describe('Incidents List', () => { + let wrapper; + const newIssuePath = 'namespace/project/-/issues/new'; + const emptyListSvgPath = '/assets/empty.svg'; + const incidentTemplateName = 'incident'; + const incidentType = 'incident'; + const incidentsCount = { + opened: 14, + closed: 1, + all: 16, + }; + + const findTable = () => wrapper.find(GlTable); + const findTableRows = () => wrapper.findAll('table tbody tr'); + const findAlert = () => wrapper.find(GlAlert); + const findLoader = () => wrapper.find(GlLoadingIcon); + const findTimeAgo = () => wrapper.findAll(TimeAgoTooltip); + const findDateColumnHeader = () => + wrapper.find('[data-testid="incident-management-created-at-sort"]'); + const findSearch = () => wrapper.find(GlSearchBoxByType); + const findAssingees = () => wrapper.findAll('[data-testid="incident-assignees"]'); + const findCreateIncidentBtn = () => wrapper.find('[data-testid="createIncidentBtn"]'); + const findClosedIcon = () => wrapper.findAll("[data-testid='incident-closed']"); + const findPagination = () => wrapper.find(GlPagination); + const findStatusFilterTabs = () => wrapper.findAll(GlTab); + const findStatusFilterBadge = () => wrapper.findAll(GlBadge); + const findStatusTabs = () => wrapper.find(GlTabs); + const findEmptyState = () => wrapper.find(GlEmptyState); + + function mountComponent({ data = { incidents: [], incidentsCount: {} }, loading = false }) { + wrapper = mount(IncidentsList, { + data() { + return data; + }, + mocks: { + $apollo: { + queries: { + incidents: { + loading, + }, + }, + }, + }, + provide: { + projectPath: '/project/path', + newIssuePath, + incidentTemplateName, + incidentType, + issuePath: '/project/isssues', + publishedAvailable: true, + emptyListSvgPath, + }, + stubs: { + GlButton: true, + GlAvatar: true, + }, + }); + } + + afterEach(() => { + if (wrapper) { + wrapper.destroy(); + wrapper = null; + } + }); + + it('shows the loading state', () => { + mountComponent({ + loading: true, + }); + expect(findLoader().exists()).toBe(true); + }); + + it('shows empty state', () => { + mountComponent({ + data: { incidents: { list: [] }, incidentsCount: {} }, + loading: false, + }); + expect(findEmptyState().exists()).toBe(true); + }); + + it('shows error state', () => { + mountComponent({ + data: { incidents: { list: [] }, incidentsCount: { all: 0 }, errored: true }, + loading: false, + }); + expect(findTable().text()).toContain(I18N.noIncidents); + expect(findAlert().exists()).toBe(true); + }); + + describe('Incident Management list', () => { + beforeEach(() => { + mountComponent({ + data: { incidents: { list: mockIncidents }, incidentsCount }, + loading: false, + }); + }); + + it('renders rows based on provided data', () => { + expect(findTableRows().length).toBe(mockIncidents.length); + }); + + it('renders a createdAt with timeAgo component per row', () => { + expect(findTimeAgo().length).toBe(mockIncidents.length); + }); + + describe('Assignees', () => { + it('shows Unassigned when there are no assignees', () => { + expect( + findAssingees() + .at(0) + .text(), + ).toBe(I18N.unassigned); + }); + + it('renders an avatar component when there is an assignee', () => { + const avatar = findAssingees() + .at(1) + .find(GlAvatar); + const { src, label } = avatar.attributes(); + const { name, avatarUrl } = mockIncidents[1].assignees.nodes[0]; + + expect(avatar.exists()).toBe(true); + expect(label).toBe(name); + expect(src).toBe(avatarUrl); + }); + + it('contains a link to the issue details', () => { + findTableRows() + .at(0) + .trigger('click'); + expect(visitUrl).toHaveBeenCalledWith(joinPaths(`/project/isssues/`, mockIncidents[0].iid)); + }); + + it('renders a closed icon for closed incidents', () => { + expect(findClosedIcon().length).toBe( + mockIncidents.filter(({ state }) => state === 'closed').length, + ); + }); + }); + }); + + describe('Create Incident', () => { + beforeEach(() => { + mountComponent({ + data: { incidents: { list: mockIncidents }, incidentsCount: {} }, + loading: false, + }); + }); + + it('shows the button linking to new incidents page with prefilled incident template when clicked', () => { + expect(findCreateIncidentBtn().exists()).toBe(true); + findCreateIncidentBtn().trigger('click'); + expect(mergeUrlParams).toHaveBeenCalledWith( + { issuable_template: incidentTemplateName, 'issue[issue_type]': incidentType }, + newIssuePath, + ); + }); + + it('sets button loading on click', () => { + findCreateIncidentBtn().vm.$emit('click'); + return wrapper.vm.$nextTick().then(() => { + expect(findCreateIncidentBtn().attributes('loading')).toBe('true'); + }); + }); + }); + + describe('Pagination', () => { + beforeEach(() => { + mountComponent({ + data: { + incidents: { + list: mockIncidents, + pageInfo: { hasNextPage: true, hasPreviousPage: true }, + }, + incidentsCount, + errored: false, + }, + loading: false, + }); + }); + + it('should render pagination', () => { + expect(wrapper.find(GlPagination).exists()).toBe(true); + }); + + describe('prevPage', () => { + it('returns prevPage button', () => { + findPagination().vm.$emit('input', 3); + + return wrapper.vm.$nextTick(() => { + expect( + findPagination() + .findAll('.page-item') + .at(0) + .text(), + ).toBe('Prev'); + }); + }); + + it('returns prevPage number', () => { + findPagination().vm.$emit('input', 3); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.prevPage).toBe(2); + }); + }); + + it('returns 0 when it is the first page', () => { + findPagination().vm.$emit('input', 1); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.prevPage).toBe(0); + }); + }); + }); + + describe('nextPage', () => { + it('returns nextPage button', () => { + findPagination().vm.$emit('input', 3); + + return wrapper.vm.$nextTick(() => { + expect( + findPagination() + .findAll('.page-item') + .at(1) + .text(), + ).toBe('Next'); + }); + }); + + it('returns nextPage number', () => { + mountComponent({ + data: { + incidents: { + list: [...mockIncidents, ...mockIncidents, ...mockIncidents], + pageInfo: { hasNextPage: true, hasPreviousPage: true }, + }, + incidentsCount, + errored: false, + }, + loading: false, + }); + findPagination().vm.$emit('input', 1); + + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.nextPage).toBe(2); + }); + }); + + it('returns `null` when currentPage is already last page', () => { + findStatusTabs().vm.$emit('input', 1); + findPagination().vm.$emit('input', 1); + return wrapper.vm.$nextTick(() => { + expect(wrapper.vm.nextPage).toBeNull(); + }); + }); + }); + + describe('Search', () => { + beforeEach(() => { + mountComponent({ + data: { + incidents: { + list: mockIncidents, + pageInfo: { hasNextPage: true, hasPreviousPage: true }, + }, + incidentsCount, + errored: false, + }, + loading: false, + }); + }); + + it('renders the search component for incidents', () => { + expect(findSearch().exists()).toBe(true); + }); + + it('sets the `searchTerm` graphql variable', () => { + const SEARCH_TERM = 'Simple Incident'; + + findSearch().vm.$emit('input', SEARCH_TERM); + + expect(wrapper.vm.$data.searchTerm).toBe(SEARCH_TERM); + }); + }); + + describe('Status Filter Tabs', () => { + beforeEach(() => { + mountComponent({ + data: { incidents: mockIncidents, incidentsCount }, + loading: false, + stubs: { + GlTab: true, + }, + }); + }); + + it('should display filter tabs', () => { + const tabs = findStatusFilterTabs().wrappers; + + tabs.forEach((tab, i) => { + expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status); + }); + }); + + it('should display filter tabs with alerts count badge for each status', () => { + const tabs = findStatusFilterTabs().wrappers; + const badges = findStatusFilterBadge(); + + tabs.forEach((tab, i) => { + const status = INCIDENT_STATUS_TABS[i].status.toLowerCase(); + expect(tab.attributes('data-testid')).toContain(INCIDENT_STATUS_TABS[i].status); + expect(badges.at(i).text()).toContain(incidentsCount[status]); + }); + }); + }); + }); + + describe('sorting the incident list by column', () => { + beforeEach(() => { + mountComponent({ + data: { incidents: mockIncidents, incidentsCount }, + loading: false, + }); + }); + + it('updates sort with new direction and column key', () => { + expect(findDateColumnHeader().attributes('aria-sort')).toBe('descending'); + + findDateColumnHeader().trigger('click'); + return wrapper.vm.$nextTick(() => { + expect(findDateColumnHeader().attributes('aria-sort')).toBe('ascending'); + }); + }); + }); +}); diff --git a/spec/frontend/incidents/mocks/incidents.json b/spec/frontend/incidents/mocks/incidents.json new file mode 100644 index 00000000000..4eab709e53f --- /dev/null +++ b/spec/frontend/incidents/mocks/incidents.json @@ -0,0 +1,39 @@ +[ + { + "iid": "15", + "title": "New: Incident", + "createdAt": "2020-06-03T15:46:08Z", + "assignees": {}, + "state": "opened" + }, + { + "iid": "14", + "title": "Create issue4", + "createdAt": "2020-05-19T09:26:07Z", + "assignees": { + "nodes": [ + { + "name": "Benjamin Braun", + "username": "kami.hegmann", + "avatarUrl": "https://invalid'", + "webUrl": "https://invalid" + } + ] + }, + "state": "opened" + }, + { + "iid": "13", + "title": "Create issue3", + "createdAt": "2020-05-19T08:53:55Z", + "assignees": {}, + "state": "closed" + }, + { + "iid": "12", + "title": "Create issue2", + "createdAt": "2020-05-18T17:13:35Z", + "assignees": {}, + "state": "closed" + } +] 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 dd3589e2951..f3f610e4bb7 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 @@ -81,19 +81,23 @@ exports[`Alert integration settings form default state should match the default </gl-form-checkbox-stub> </gl-form-group-stub> - <gl-button-stub - category="tertiary" - class="js-no-auto-disable" - data-qa-selector="save_changes_button" - icon="" - size="medium" - type="submit" - variant="success" + <div + class="gl-display-flex gl-justify-content-end" > + <gl-button-stub + category="primary" + class="js-no-auto-disable" + data-qa-selector="save_changes_button" + icon="" + size="medium" + type="submit" + variant="success" + > + + Save changes - Save changes - - </gl-button-stub> + </gl-button-stub> + </div> </form> </div> `; diff --git a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap index 5f355ee8261..3ad4c13382d 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/incidents_settings_tabs_spec.js.snap @@ -9,16 +9,16 @@ exports[`IncidentsSettingTabs should render the component 1`] = ` <div class="settings-header" > - <h3 - class="h4" + <h4 + class="gl-my-3! gl-py-1" > Incidents - </h3> + </h4> <gl-button-stub - category="tertiary" + category="primary" class="js-settings-toggle" icon="" size="medium" diff --git a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap index 17ada722034..78bb238fcb6 100644 --- a/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap +++ b/spec/frontend/incidents_settings/components/__snapshots__/pagerduty_form_spec.js.snap @@ -35,27 +35,31 @@ exports[`Alert integration settings form should match the default snapshot 1`] = /> <div - class="gl-text-gray-400 gl-pt-2" + class="gl-text-gray-200 gl-pt-2" > <gl-sprintf-stub message="Create a GitLab issue for each PagerDuty incident by %{docsLink}" /> </div> - <gl-button-stub - category="tertiary" - class="gl-mt-3" - data-testid="webhook-reset-btn" - icon="" - role="button" - size="medium" - tabindex="0" - variant="default" + <div + class="gl-display-flex gl-justify-content-end" > + <gl-button-stub + category="primary" + class="gl-mt-3" + data-testid="webhook-reset-btn" + icon="" + role="button" + size="medium" + tabindex="0" + variant="default" + > + + Reset webhook URL - Reset webhook URL - - </gl-button-stub> + </gl-button-stub> + </div> <gl-modal-stub modalclass="" @@ -72,18 +76,22 @@ exports[`Alert integration settings form should match the default snapshot 1`] = </gl-modal-stub> </gl-form-group-stub> - <gl-button-stub - category="tertiary" - class="js-no-auto-disable" - icon="" - size="medium" - type="submit" - variant="success" + <div + class="gl-display-flex gl-justify-content-end" > + <gl-button-stub + category="primary" + class="js-no-auto-disable" + icon="" + size="medium" + type="submit" + variant="success" + > + + Save changes - Save changes - - </gl-button-stub> + </gl-button-stub> + </div> </form> </div> `; diff --git a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js index 58f9a318808..5010fc0bb5c 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -1,9 +1,9 @@ -import axios from '~/lib/utils/axios_utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import axios from '~/lib/utils/axios_utils'; import httpStatusCodes from '~/lib/utils/http_status'; import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; import { ERROR_MSG } from '~/incidents_settings/constants'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; jest.mock('~/flash'); diff --git a/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js index 47e2aecc108..c56b9ed2a69 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_tabs_spec.js @@ -6,9 +6,7 @@ describe('IncidentsSettingTabs', () => { let wrapper; beforeEach(() => { - wrapper = shallowMount(IncidentsSettingTabs, { - provide: { glFeatures: { pagerdutyWebhook: true } }, - }); + wrapper = shallowMount(IncidentsSettingTabs); }); afterEach(() => { diff --git a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js index 521094ad54c..50d0de8a753 100644 --- a/spec/frontend/incidents_settings/components/pagerduty_form_spec.js +++ b/spec/frontend/incidents_settings/components/pagerduty_form_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; -import PagerDutySettingsForm from '~/incidents_settings/components/pagerduty_form.vue'; import { GlAlert, GlModal } from '@gitlab/ui'; +import PagerDutySettingsForm from '~/incidents_settings/components/pagerduty_form.vue'; describe('Alert integration settings form', () => { let wrapper; diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index 3a7a0efcab7..53234419f5f 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; import { GlFormGroup, GlFormCheckbox, GlFormInput, GlFormSelect, GlFormTextarea } from '@gitlab/ui'; +import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; describe('DynamicField', () => { let wrapper; diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 482c6a439f2..f8e2eb5e7f4 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +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'; @@ -7,7 +8,6 @@ import JiraTriggerFields from '~/integrations/edit/components/jira_trigger_field import JiraIssuesFields from '~/integrations/edit/components/jira_issues_fields.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; -import { mockIntegrationProps } from 'jest/integrations/edit/mock_data'; describe('IntegrationForm', () => { let wrapper; diff --git a/spec/frontend/integrations/edit/components/trigger_fields_spec.js b/spec/frontend/integrations/edit/components/trigger_fields_spec.js index 41bccb8ada0..df12c70f9f5 100644 --- a/spec/frontend/integrations/edit/components/trigger_fields_spec.js +++ b/spec/frontend/integrations/edit/components/trigger_fields_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import { GlFormGroup, GlFormCheckbox, GlFormInput } from '@gitlab/ui'; +import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; describe('TriggerFields', () => { let wrapper; diff --git a/spec/frontend/integrations/edit/store/actions_spec.js b/spec/frontend/integrations/edit/store/actions_spec.js index c3ce6e51a3d..5356c0a411b 100644 --- a/spec/frontend/integrations/edit/store/actions_spec.js +++ b/spec/frontend/integrations/edit/store/actions_spec.js @@ -1,9 +1,8 @@ +import testAction from 'helpers/vuex_action_helper'; import createState from '~/integrations/edit/store/state'; import { setOverride } from '~/integrations/edit/store/actions'; import * as types from '~/integrations/edit/store/mutation_types'; -import testAction from 'helpers/vuex_action_helper'; - describe('Integration form store actions', () => { let state; diff --git a/spec/frontend/issuable_form_spec.js b/spec/frontend/issuable_form_spec.js new file mode 100644 index 00000000000..009ca28ff78 --- /dev/null +++ b/spec/frontend/issuable_form_spec.js @@ -0,0 +1,56 @@ +import $ from 'jquery'; + +import IssuableForm from '~/issuable_form'; + +function createIssuable() { + const instance = new IssuableForm($(document.createElement('form'))); + + instance.titleField = $(document.createElement('input')); + + return instance; +} + +describe('IssuableForm', () => { + let instance; + + beforeEach(() => { + instance = createIssuable(); + }); + + describe('removeWip', () => { + it.each` + prefix + ${'wip '} + ${' wIP: '} + ${'[WIp] '} + ${'wIP:'} + ${' [WIp]'} + ${'drAft '} + ${'draFT: '} + ${' [DRaft] '} + ${'drAft:'} + ${'[draFT]'} + ${' dRaFt - '} + ${'dRaFt - '} + ${'(draft) '} + ${' (DrafT)'} + ${'wip wip: [wip] draft draft - draft: [draft] (draft)'} + `('removes "$prefix" from the beginning of the title', ({ prefix }) => { + instance.titleField.val(`${prefix}The Issuable's Title Value`); + + instance.removeWip(); + + expect(instance.titleField.val()).toBe("The Issuable's Title Value"); + }); + }); + + describe('addWip', () => { + it("properly adds the work in progress prefix to the Issuable's title", () => { + instance.titleField.val("The Issuable's Title Value"); + + instance.addWip(); + + expect(instance.titleField.val()).toBe("Draft: The Issuable's Title Value"); + }); + }); +}); diff --git a/spec/frontend/issuable_suggestions/components/item_spec.js b/spec/frontend/issuable_suggestions/components/item_spec.js index 36799f4ee9f..ad37ccd2ca5 100644 --- a/spec/frontend/issuable_suggestions/components/item_spec.js +++ b/spec/frontend/issuable_suggestions/components/item_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { GlTooltip, GlLink } 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'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Issuable suggestions suggestion component', () => { let vm; diff --git a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap index 3e445319746..c327b7de827 100644 --- a/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap +++ b/spec/frontend/issuables_list/components/__snapshots__/issuables_list_app_spec.js.snap @@ -2,7 +2,6 @@ exports[`Issuables list component with empty issues response with all state should display a catch-all if there are no issues to show 1`] = ` <gl-empty-state-stub - description="The Issue Tracker is the place to add things that need to be improved or solved in a project. You can register or sign in to create issues for this project." svgpath="/emptySvg" title="There are no issues to show" /> diff --git a/spec/frontend/issuables_list/components/issuable_spec.js b/spec/frontend/issuables_list/components/issuable_spec.js index 87868b7eeff..6ede46a602a 100644 --- a/spec/frontend/issuables_list/components/issuable_spec.js +++ b/spec/frontend/issuables_list/components/issuable_spec.js @@ -76,8 +76,9 @@ describe('Issuable component', () => { }); const checkExists = findFn => () => findFn().exists(); - const hasConfidentialIcon = () => - wrapper.findAll(GlIcon).wrappers.some(iconWrapper => iconWrapper.props('name') === 'eye-slash'); + const hasIcon = (iconName, iconWrapper = wrapper) => + iconWrapper.findAll(GlIcon).wrappers.some(icon => icon.props('name') === iconName); + const hasConfidentialIcon = () => hasIcon('eye-slash'); const findTaskStatus = () => wrapper.find('.task-status'); const findOpenedAgoContainer = () => wrapper.find('[data-testid="openedByMessage"]'); const findAuthor = () => wrapper.find({ ref: 'openedAgoByContainer' }); @@ -85,18 +86,20 @@ describe('Issuable component', () => { const findMilestoneTooltip = () => findMilestone().attributes('title'); const findDueDate = () => wrapper.find('.js-due-date'); const findLabels = () => wrapper.findAll(GlLabel); - const findWeight = () => wrapper.find('.js-weight'); + const findWeight = () => wrapper.find('[data-testid="weight"]'); const findAssignees = () => wrapper.find(IssueAssignees); - const findMergeRequestsCount = () => wrapper.find('.js-merge-requests'); - const findUpvotes = () => wrapper.find('.js-upvotes'); - const findDownvotes = () => wrapper.find('.js-downvotes'); - const findNotes = () => wrapper.find('.js-notes'); + const findBlockingIssuesCount = () => wrapper.find('[data-testid="blocking-issues"]'); + const findMergeRequestsCount = () => wrapper.find('[data-testid="merge-requests"]'); + const findUpvotes = () => wrapper.find('[data-testid="upvotes"]'); + const findDownvotes = () => wrapper.find('[data-testid="downvotes"]'); + const findNotes = () => wrapper.find('[data-testid="notes-count"]'); const findBulkCheckbox = () => wrapper.find('input.selected-issuable'); const findScopedLabels = () => findLabels().filter(w => isScopedLabel({ title: w.text() })); 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 findHealthStatus = () => wrapper.find('.health-status'); describe('when mounted', () => { it('initializes user popovers', () => { @@ -181,6 +184,7 @@ describe('Issuable component', () => { ${'due date'} | ${checkExists(findDueDate)} ${'labels'} | ${checkExists(findLabels)} ${'weight'} | ${checkExists(findWeight)} + ${'blocking issues count'} | ${checkExists(findBlockingIssuesCount)} ${'merge request count'} | ${checkExists(findMergeRequestsCount)} ${'upvotes'} | ${checkExists(findUpvotes)} ${'downvotes'} | ${checkExists(findDownvotes)} @@ -286,11 +290,7 @@ describe('Issuable component', () => { it('renders milestone', () => { expect(findMilestone().exists()).toBe(true); - expect( - findMilestone() - .find('.fa-clock-o') - .exists(), - ).toBe(true); + expect(hasIcon('clock', findMilestone())).toBe(true); expect(findMilestone().text()).toEqual(TEST_MILESTONE.title); }); @@ -430,11 +430,12 @@ describe('Issuable component', () => { }); describe.each` - desc | key | finder - ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount} - ${'with upvote count'} | ${'upvotes'} | ${findUpvotes} - ${'with downvote count'} | ${'downvotes'} | ${findDownvotes} - ${'with notes count'} | ${'user_notes_count'} | ${findNotes} + desc | key | finder + ${'with blocking issues count'} | ${'blocking_issues_count'} | ${findBlockingIssuesCount} + ${'with merge requests count'} | ${'merge_requests_count'} | ${findMergeRequestsCount} + ${'with upvote count'} | ${'upvotes'} | ${findUpvotes} + ${'with downvote count'} | ${'downvotes'} | ${findDownvotes} + ${'with notes count'} | ${'user_notes_count'} | ${findNotes} `('$desc', ({ key, finder }) => { beforeEach(() => { issuable[key] = TEST_META_COUNT; @@ -442,7 +443,7 @@ describe('Issuable component', () => { factory({ issuable }); }); - it('renders merge requests count', () => { + it('renders correct count', () => { expect(finder().exists()).toBe(true); expect(finder().text()).toBe(TEST_META_COUNT.toString()); expect(finder().classes('no-comments')).toBe(false); @@ -474,4 +475,19 @@ describe('Issuable component', () => { }); }); }); + + if (IS_EE) { + describe('with health status', () => { + it('renders health status tag', () => { + factory({ issuable }); + expect(findHealthStatus().exists()).toBe(true); + }); + + it('does not render when health status is absent', () => { + issuable.health_status = null; + factory({ issuable }); + expect(findHealthStatus().exists()).toBe(false); + }); + }); + } }); diff --git a/spec/frontend/issuables_list/components/issuables_list_app_spec.js b/spec/frontend/issuables_list/components/issuables_list_app_spec.js index 9f4995a54ee..65b87ddf6a6 100644 --- a/spec/frontend/issuables_list/components/issuables_list_app_spec.js +++ b/spec/frontend/issuables_list/components/issuables_list_app_spec.js @@ -4,14 +4,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlEmptyState, GlPagination, GlSkeletonLoading } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; import { TEST_HOST } from 'helpers/test_constants'; -import flash from '~/flash'; +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 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'; -jest.mock('~/flash', () => jest.fn()); +jest.mock('~/flash'); jest.mock('~/issuables_list/eventhub'); jest.mock('~/lib/utils/common_utils', () => ({ ...jest.requireActual('~/lib/utils/common_utils'), @@ -21,7 +21,7 @@ jest.mock('~/lib/utils/common_utils', () => ({ const TEST_LOCATION = `${TEST_HOST}/issues`; const TEST_ENDPOINT = '/issues'; const TEST_CREATE_ISSUES_PATH = '/createIssue'; -const TEST_EMPTY_SVG_PATH = '/emptySvg'; +const TEST_SVG_PATH = '/emptySvg'; const setUrl = query => { window.location.href = `${TEST_LOCATION}${query}`; @@ -48,11 +48,15 @@ describe('Issuables list component', () => { }; const factory = (props = { sortKey: 'priority' }) => { + const emptyStateMeta = { + createIssuePath: TEST_CREATE_ISSUES_PATH, + svgPath: TEST_SVG_PATH, + }; + wrapper = shallowMount(IssuablesListApp, { propsData: { endpoint: TEST_ENDPOINT, - createIssuePath: TEST_CREATE_ISSUES_PATH, - emptySvgPath: TEST_EMPTY_SVG_PATH, + emptyStateMeta, ...props, }, }); @@ -117,9 +121,10 @@ describe('Issuables list component', () => { expect(wrapper.vm).toMatchObject({ // Props canBulkEdit: false, - createIssuePath: TEST_CREATE_ISSUES_PATH, - emptySvgPath: TEST_EMPTY_SVG_PATH, - + emptyStateMeta: { + createIssuePath: TEST_CREATE_ISSUES_PATH, + svgPath: TEST_SVG_PATH, + }, // Data filters: { state: 'opened', diff --git a/spec/frontend/issuables_list/issuable_list_test_data.js b/spec/frontend/issuables_list/issuable_list_test_data.js index 19d8ee7f71a..313aa15bd31 100644 --- a/spec/frontend/issuables_list/issuable_list_test_data.js +++ b/spec/frontend/issuables_list/issuable_list_test_data.js @@ -18,6 +18,7 @@ export const simpleIssue = { }, assignee: null, user_notes_count: 0, + blocking_issues_count: 0, merge_requests_count: 0, upvotes: 0, downvotes: 0, @@ -29,6 +30,7 @@ export const simpleIssue = { references: { relative: 'html-boilerplate#45', }, + health_status: 'on_track', }; export const testLabels = [ diff --git a/spec/frontend/issue_show/components/app_spec.js b/spec/frontend/issue_show/components/app_spec.js index d970fd349e7..f76f42cb9ae 100644 --- a/spec/frontend/issue_show/components/app_spec.js +++ b/spec/frontend/issue_show/components/app_spec.js @@ -2,6 +2,7 @@ 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'; @@ -22,6 +23,8 @@ const zoomMeetingUrl = 'https://gitlab.zoom.us/j/95919234811'; const publishedIncidentUrl = 'https://status.com/'; describe('Issuable output', () => { + useMockIntersectionObserver(); + let mock; let realtimeRequestCount = 0; let wrapper; @@ -45,11 +48,6 @@ describe('Issuable output', () => { </div> `); - window.IntersectionObserver = class { - disconnect = jest.fn(); - observe = jest.fn(); - }; - mock = new MockAdapter(axios); mock .onGet('/gitlab-org/gitlab-shell/-/issues/9/realtime_changes/realtime_changes') @@ -84,7 +82,6 @@ describe('Issuable output', () => { }); afterEach(() => { - delete window.IntersectionObserver; mock.restore(); realtimeRequestCount = 0; diff --git a/spec/frontend/issue_show/components/issuable_header_warnings_spec.js b/spec/frontend/issue_show/components/issuable_header_warnings_spec.js deleted file mode 100644 index 5a166812d84..00000000000 --- a/spec/frontend/issue_show/components/issuable_header_warnings_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -import { shallowMount, createLocalVue } from '@vue/test-utils'; -import Vuex from 'vuex'; -import IssuableHeaderWarnings from '~/issue_show/components/issuable_header_warnings.vue'; -import createStore from '~/notes/stores'; - -const localVue = createLocalVue(); -localVue.use(Vuex); - -describe('IssuableHeaderWarnings', () => { - let wrapper; - let store; - - const findConfidential = () => wrapper.find('[data-testid="confidential"]'); - const findLocked = () => wrapper.find('[data-testid="locked"]'); - const confidentialIconName = () => findConfidential().attributes('name'); - const lockedIconName = () => findLocked().attributes('name'); - - const createComponent = () => { - wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue }); - }; - - beforeEach(() => { - store = createStore(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - store = null; - }); - - describe('when confidential is on', () => { - beforeEach(() => { - store.state.noteableData.confidential = true; - - createComponent(); - }); - - it('renders the confidential icon', () => { - expect(confidentialIconName()).toBe('eye-slash'); - }); - }); - - describe('when confidential is off', () => { - beforeEach(() => { - store.state.noteableData.confidential = false; - - createComponent(); - }); - - it('does not find the component', () => { - expect(findConfidential().exists()).toBe(false); - }); - }); - - describe('when discussion locked is on', () => { - beforeEach(() => { - store.state.noteableData.discussion_locked = true; - - createComponent(); - }); - - it('renders the locked icon', () => { - expect(lockedIconName()).toBe('lock'); - }); - }); - - describe('when discussion locked is off', () => { - beforeEach(() => { - store.state.noteableData.discussion_locked = false; - - createComponent(); - }); - - it('does not find the component', () => { - expect(findLocked().exists()).toBe(false); - }); - }); -}); diff --git a/spec/frontend/jira_import/components/jira_import_app_spec.js b/spec/frontend/jira_import/components/jira_import_app_spec.js index 64b4461d7b2..27314a0eb6e 100644 --- a/spec/frontend/jira_import/components/jira_import_app_spec.js +++ b/spec/frontend/jira_import/components/jira_import_app_spec.js @@ -1,21 +1,26 @@ import { GlAlert, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import axios from '~/lib/utils/axios_utils'; import JiraImportApp from '~/jira_import/components/jira_import_app.vue'; import JiraImportForm from '~/jira_import/components/jira_import_form.vue'; import JiraImportProgress from '~/jira_import/components/jira_import_progress.vue'; import JiraImportSetup from '~/jira_import/components/jira_import_setup.vue'; -import initiateJiraImportMutation from '~/jira_import/queries/initiate_jira_import.mutation.graphql'; -import getJiraUserMappingMutation from '~/jira_import/queries/get_jira_user_mapping.mutation.graphql'; -import { imports, issuesPath, jiraIntegrationPath, jiraProjects, userMappings } from '../mock_data'; +import { + imports, + issuesPath, + jiraIntegrationPath, + jiraProjects, + projectId, + projectPath, +} from '../mock_data'; describe('JiraImportApp', () => { - let axiosMock; - let mutateSpy; let wrapper; + const inProgressIllustration = 'in-progress-illustration.svg'; + + const setupIllustration = 'setup-illustration.svg'; + const getFormComponent = () => wrapper.find(JiraImportForm); const getProgressComponent = () => wrapper.find(JiraImportProgress); @@ -29,28 +34,22 @@ describe('JiraImportApp', () => { const mountComponent = ({ isJiraConfigured = true, errorMessage = '', - selectedProject = 'MTG', showAlert = false, isInProgress = false, loading = false, - mutate = mutateSpy, - mountFunction = shallowMount, } = {}) => - mountFunction(JiraImportApp, { + shallowMount(JiraImportApp, { propsData: { - inProgressIllustration: 'in-progress-illustration.svg', + inProgressIllustration, isJiraConfigured, issuesPath, jiraIntegrationPath, - projectId: '5', - projectPath: 'gitlab-org/gitlab-test', - setupIllustration: 'setup-illustration.svg', + projectId, + projectPath, + setupIllustration, }, data() { return { - isSubmitting: false, - selectedProject, - userMappings, errorMessage, showAlert, jiraImportDetails: { @@ -64,26 +63,11 @@ describe('JiraImportApp', () => { mocks: { $apollo: { loading, - mutate, }, }, }); - beforeEach(() => { - axiosMock = new AxiosMockAdapter(axios); - mutateSpy = jest.fn(() => - Promise.resolve({ - data: { - jiraImportStart: { errors: [] }, - jiraImportUsers: { jiraUsers: [], errors: [] }, - }, - }), - ); - }); - afterEach(() => { - axiosMock.restore(); - mutateSpy.mockRestore(); wrapper.destroy(); wrapper = null; }); @@ -176,111 +160,84 @@ describe('JiraImportApp', () => { }); }); - describe('import in progress screen', () => { + describe('import setup component', () => { + beforeEach(() => { + wrapper = mountComponent({ isJiraConfigured: false }); + }); + + it('receives the illustration', () => { + expect(getSetupComponent().props('illustration')).toBe(setupIllustration); + }); + + it('receives the path to the Jira integration page', () => { + expect(getSetupComponent().props('jiraIntegrationPath')).toBe(jiraIntegrationPath); + }); + }); + + describe('import in progress component', () => { beforeEach(() => { wrapper = mountComponent({ isInProgress: true }); }); - it('shows the illustration', () => { - expect(getProgressComponent().props('illustration')).toBe('in-progress-illustration.svg'); + it('receives the illustration', () => { + expect(getProgressComponent().props('illustration')).toBe(inProgressIllustration); }); - it('shows the name of the most recent import initiator', () => { + it('receives the name of the most recent import initiator', () => { expect(getProgressComponent().props('importInitiator')).toBe('Jane Doe'); }); - it('shows the name of the most recent imported project', () => { + it('receives the name of the most recent imported project', () => { expect(getProgressComponent().props('importProject')).toBe('MTG'); }); - it('shows the time of the most recent import', () => { + it('receives the time of the most recent import', () => { expect(getProgressComponent().props('importTime')).toBe('2020-04-09T16:17:18+00:00'); }); - it('has the path to the issues page', () => { + it('receives the path to the issues page', () => { expect(getProgressComponent().props('issuesPath')).toBe('gitlab-org/gitlab-test/-/issues'); }); }); - describe('jira import form screen', () => { - describe('when selected project has been imported before', () => { - it('shows jira-import::MTG-3 label since project MTG has been imported 2 time before', () => { - wrapper = mountComponent(); - - expect(getFormComponent().props('importLabel')).toBe('jira-import::MTG-3'); - }); - - it('shows warning alert to explain project MTG has been imported 2 times before', () => { - wrapper = mountComponent({ mountFunction: mount }); - - expect(getAlert().text()).toBe( - 'You have imported from this project 2 times before. Each new import will create duplicate issues.', - ); - }); + describe('import form component', () => { + beforeEach(() => { + wrapper = mountComponent(); }); - describe('when selected project has not been imported before', () => { - beforeEach(() => { - wrapper = mountComponent({ selectedProject: 'MJP' }); - }); - - it('shows jira-import::MJP-1 label since project MJP has not been imported before', () => { - expect(getFormComponent().props('importLabel')).toBe('jira-import::MJP-1'); - }); - - it('does not show warning alert since project MJP has not been imported before', () => { - expect(getAlert().exists()).toBe(false); - }); + it('receives the illustration', () => { + expect(getFormComponent().props('issuesPath')).toBe(issuesPath); }); - }); - describe('initiating a Jira import', () => { - it('calls the mutation with the expected arguments', () => { - wrapper = mountComponent(); + it('receives the name of the most recent import initiator', () => { + expect(getFormComponent().props('jiraImports')).toEqual(imports); + }); - const mutationArguments = { - mutation: initiateJiraImportMutation, - variables: { - input: { - jiraProjectKey: 'MTG', - projectPath: 'gitlab-org/gitlab-test', - usersMapping: [ - { - jiraAccountId: 'aei23f98f-q23fj98qfj', - gitlabId: 15, - }, - { - jiraAccountId: 'fu39y8t34w-rq3u289t3h4i', - gitlabId: undefined, - }, - ], - }, - }, - }; + it('receives the name of the most recent imported project', () => { + expect(getFormComponent().props('jiraProjects')).toEqual(jiraProjects); + }); - getFormComponent().vm.$emit('initiateJiraImport', 'MTG'); + it('receives the project ID', () => { + expect(getFormComponent().props('projectId')).toBe(projectId); + }); - expect(mutateSpy).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); + it('receives the project path', () => { + expect(getFormComponent().props('projectPath')).toBe(projectPath); }); - it('shows alert message with error message on error', () => { - const mutate = jest.fn(() => Promise.reject()); + it('shows an alert when it emits an error', async () => { + expect(getAlert().exists()).toBe(false); - wrapper = mountComponent({ mutate }); + getFormComponent().vm.$emit('error', 'There was an error'); - getFormComponent().vm.$emit('initiateJiraImport', 'MTG'); + await Vue.nextTick(); - // One tick doesn't update the dom to the desired state so we have two ticks here - return Vue.nextTick() - .then(Vue.nextTick) - .then(() => { - expect(getAlert().text()).toBe('There was an error importing the Jira project.'); - }); + expect(getAlert().exists()).toBe(true); }); }); describe('alert', () => { - it('can be dismissed', () => { + it('can be dismissed', async () => { wrapper = mountComponent({ errorMessage: 'There was an error importing the Jira project.', showAlert: true, @@ -291,40 +248,9 @@ describe('JiraImportApp', () => { getAlert().vm.$emit('dismiss'); - return Vue.nextTick().then(() => { - expect(getAlert().exists()).toBe(false); - }); - }); - }); - - describe('on mount', () => { - it('makes a GraphQL mutation call to get user mappings', () => { - wrapper = mountComponent(); + await Vue.nextTick(); - const mutationArguments = { - mutation: getJiraUserMappingMutation, - variables: { - input: { - projectPath: 'gitlab-org/gitlab-test', - }, - }, - }; - - expect(mutateSpy).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); - }); - - it('does not make a GraphQL mutation call to get user mappings when Jira is not configured', () => { - wrapper = mountComponent({ isJiraConfigured: false }); - - expect(mutateSpy).not.toHaveBeenCalled(); - }); - - it('shows error message when there is an error with the GraphQL mutation call', () => { - const mutate = jest.fn(() => Promise.reject()); - - wrapper = mountComponent({ mutate }); - - expect(getAlert().exists()).toBe(true); + expect(getAlert().exists()).toBe(false); }); }); }); 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 685b0288e92..7cc7b40f4c8 100644 --- a/spec/frontend/jira_import/components/jira_import_form_spec.js +++ b/spec/frontend/jira_import/components/jira_import_form_spec.js @@ -1,56 +1,97 @@ -import { GlButton, GlFormSelect, GlLabel, GlTable } from '@gitlab/ui'; +import { GlAlert, GlButton, GlNewDropdown, 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'; import axios from '~/lib/utils/axios_utils'; import JiraImportForm from '~/jira_import/components/jira_import_form.vue'; -import { issuesPath, jiraProjects, userMappings } from '../mock_data'; +import getJiraUserMappingMutation from '~/jira_import/queries/get_jira_user_mapping.mutation.graphql'; +import initiateJiraImportMutation from '~/jira_import/queries/initiate_jira_import.mutation.graphql'; +import { + imports, + issuesPath, + jiraProjects, + projectId, + projectPath, + userMappings as defaultUserMappings, +} from '../mock_data'; describe('JiraImportForm', () => { let axiosMock; + let mutateSpy; let wrapper; const currentUsername = 'mrgitlab'; - const importLabel = 'jira-import::MTG-1'; - const value = 'MTG'; + + const getAlert = () => wrapper.find(GlAlert); const getSelectDropdown = () => wrapper.find(GlFormSelect); + const getContinueButton = () => wrapper.find(GlButton); + const getCancelButton = () => wrapper.findAll(GlButton).at(1); + const getLabel = () => wrapper.find(GlLabel); + + const getTable = () => wrapper.find(GlTable); + + const getUserDropdown = () => getTable().find(GlNewDropdown); + const getHeader = name => getByRole(wrapper.element, 'columnheader', { name }); - const mountComponent = ({ isSubmitting = false, mountFunction = shallowMount } = {}) => + const mountComponent = ({ + isSubmitting = false, + loading = false, + mutate = mutateSpy, + selectedProject = 'MTG', + userMappings = defaultUserMappings, + mountFunction = shallowMount, + } = {}) => mountFunction(JiraImportForm, { propsData: { - importLabel, - isSubmitting, issuesPath, + jiraImports: imports, jiraProjects, - projectId: '5', - userMappings, - value, + projectId, + projectPath, }, data: () => ({ isFetching: false, + isSubmitting, searchTerm: '', + selectedProject, selectState: null, users: [], + userMappings, }), + mocks: { + $apollo: { + loading, + mutate, + }, + }, currentUsername, }); beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); + mutateSpy = jest.fn(() => + Promise.resolve({ + data: { + jiraImportStart: { errors: [] }, + jiraImportUsers: { jiraUsers: [], errors: [] }, + }, + }), + ); }); afterEach(() => { axiosMock.restore(); + mutateSpy.mockRestore(); wrapper.destroy(); wrapper = null; }); - describe('select dropdown', () => { + describe('select dropdown project selection', () => { it('is shown', () => { wrapper = mountComponent(); @@ -67,12 +108,34 @@ describe('JiraImportForm', () => { }); }); - it('emits an "input" event when the input select value changes', () => { - wrapper = mountComponent(); + describe('when selected project has been imported before', () => { + it('shows jira-import::MTG-3 label since project MTG has been imported 2 time before', () => { + wrapper = mountComponent(); + + expect(getLabel().props('title')).toBe('jira-import::MTG-3'); + }); + + it('shows warning alert to explain project MTG has been imported 2 times before', () => { + wrapper = mountComponent({ mountFunction: mount }); + + expect(getAlert().text()).toBe( + 'You have imported from this project 2 times before. Each new import will create duplicate issues.', + ); + }); + }); + + describe('when selected project has not been imported before', () => { + beforeEach(() => { + wrapper = mountComponent({ selectedProject: 'MJP' }); + }); - getSelectDropdown().vm.$emit('change', value); + it('shows jira-import::MJP-1 label since project MJP has not been imported before', () => { + expect(getLabel().props('title')).toBe('jira-import::MJP-1'); + }); - expect(wrapper.emitted('input')[0]).toEqual([value]); + it('does not show warning alert since project MJP has not been imported before', () => { + expect(getAlert().exists()).toBe(false); + }); }); }); @@ -81,10 +144,6 @@ describe('JiraImportForm', () => { wrapper = mountComponent(); }); - it('shows a label which will be applied to imported Jira projects', () => { - expect(wrapper.find(GlLabel).props('title')).toBe(importLabel); - }); - it('shows a heading for the user mapping section', () => { expect( getByRole(wrapper.element, 'heading', { name: 'Jira-GitLab user mapping template' }), @@ -93,7 +152,7 @@ describe('JiraImportForm', () => { it('shows information to the user', () => { expect(wrapper.find('p').text()).toBe( - 'Jira users have been matched with similar GitLab users. This can be overwritten by selecting a GitLab user from the dropdown in the "GitLab username" column. If it wasn\'t possible to match a Jira user with a GitLab user, the dropdown defaults to the user conducting the import.', + 'Jira users have been imported from the configured Jira instance. They can be mapped by selecting a GitLab user from the dropdown in the "GitLab username" column. When the form appears, the dropdown defaults to the user conducting the import.', ); }); }); @@ -121,13 +180,53 @@ describe('JiraImportForm', () => { it('shows all user mappings', () => { wrapper = mountComponent({ mountFunction: mount }); - expect(wrapper.find(GlTable).findAll('tbody tr').length).toBe(userMappings.length); + expect(getTable().findAll('tbody tr')).toHaveLength(2); }); it('shows correct information in each cell', () => { wrapper = mountComponent({ mountFunction: mount }); - expect(wrapper.find(GlTable).element).toMatchSnapshot(); + expect(getTable().element).toMatchSnapshot(); + }); + + describe('when there is no Jira->GitLab user mapping', () => { + it('shows the logged in user in the dropdown', () => { + wrapper = mountComponent({ + mountFunction: mount, + userMappings: [ + { + jiraAccountId: 'aei23f98f-q23fj98qfj', + jiraDisplayName: 'Jane Doe', + jiraEmail: 'janedoe@example.com', + gitlabId: undefined, + gitlabUsername: undefined, + }, + ], + }); + + expect(getUserDropdown().text()).toContain(currentUsername); + }); + }); + + describe('when there is a Jira->GitLab user mapping', () => { + it('shows the mapped user in the dropdown', () => { + const gitlabUsername = 'mai'; + + wrapper = mountComponent({ + mountFunction: mount, + userMappings: [ + { + jiraAccountId: 'aei23f98f-q23fj98qfj', + jiraDisplayName: 'Jane Doe', + jiraEmail: 'janedoe@example.com', + gitlabId: 14, + gitlabUsername, + }, + ], + }); + + expect(getUserDropdown().text()).toContain(gitlabUsername); + }); }); }); }); @@ -137,13 +236,13 @@ describe('JiraImportForm', () => { it('is shown', () => { wrapper = mountComponent(); - expect(wrapper.find(GlButton).text()).toBe('Continue'); + expect(getContinueButton().text()).toBe('Continue'); }); it('is in loading state when the form is submitting', async () => { wrapper = mountComponent({ isSubmitting: true }); - expect(wrapper.find(GlButton).props('loading')).toBe(true); + expect(getContinueButton().props('loading')).toBe(true); }); }); @@ -162,13 +261,61 @@ describe('JiraImportForm', () => { }); }); - describe('form', () => { - it('emits an "initiateJiraImport" event with the selected dropdown value when submitted', () => { + describe('submitting the form', () => { + it('initiates the Jira import mutation with the expected arguments', () => { wrapper = mountComponent(); + const mutationArguments = { + mutation: initiateJiraImportMutation, + variables: { + input: { + jiraProjectKey: 'MTG', + projectPath, + usersMapping: [ + { + jiraAccountId: 'aei23f98f-q23fj98qfj', + gitlabId: 15, + }, + { + jiraAccountId: 'fu39y8t34w-rq3u289t3h4i', + gitlabId: undefined, + }, + ], + }, + }, + }; + wrapper.find('form').trigger('submit'); - expect(wrapper.emitted('initiateJiraImport')[0]).toEqual([value]); + expect(mutateSpy).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); + }); + }); + + describe('on mount GraphQL user mapping mutation', () => { + it('is called with the expected arguments', () => { + wrapper = mountComponent(); + + const mutationArguments = { + mutation: getJiraUserMappingMutation, + variables: { + input: { + projectPath, + }, + }, + }; + + expect(mutateSpy).toHaveBeenCalledWith(expect.objectContaining(mutationArguments)); + }); + + describe('when there is an error when called', () => { + beforeEach(() => { + const mutate = jest.fn(() => Promise.reject()); + wrapper = mountComponent({ mutate }); + }); + + it('shows error message', () => { + expect(getAlert().exists()).toBe(true); + }); }); }); }); diff --git a/spec/frontend/jira_import/mock_data.js b/spec/frontend/jira_import/mock_data.js index a7447221b15..8ea40080f32 100644 --- a/spec/frontend/jira_import/mock_data.js +++ b/spec/frontend/jira_import/mock_data.js @@ -3,6 +3,16 @@ import { IMPORT_STATE } from '~/jira_import/utils/jira_import_utils'; export const fullPath = 'gitlab-org/gitlab-test'; +export const issuesPath = 'gitlab-org/gitlab-test/-/issues'; + +export const illustration = 'illustration.svg'; + +export const jiraIntegrationPath = 'gitlab-org/gitlab-test/-/services/jira/edit'; + +export const projectId = '5'; + +export const projectPath = 'gitlab-org/gitlab-test'; + export const queryDetails = { query: getJiraImportDetailsQuery, variables: { @@ -71,12 +81,6 @@ export const jiraImportMutationResponse = { }, }; -export const issuesPath = 'gitlab-org/gitlab-test/-/issues'; - -export const jiraIntegrationPath = 'gitlab-org/gitlab-test/-/services/jira/edit'; - -export const illustration = 'illustration.svg'; - export const jiraProjects = [ { text: 'My Jira Project (MJP)', value: 'MJP' }, { text: 'My Second Jira Project (MSJP)', value: 'MSJP' }, diff --git a/spec/frontend/jobs/components/empty_state_spec.js b/spec/frontend/jobs/components/empty_state_spec.js index c6eac4e27b3..29d0c4e07aa 100644 --- a/spec/frontend/jobs/components/empty_state_spec.js +++ b/spec/frontend/jobs/components/empty_state_spec.js @@ -1,12 +1,10 @@ -import Vue from 'vue'; -import component from '~/jobs/components/empty_state.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; +import EmptyState from '~/jobs/components/empty_state.vue'; describe('Empty State', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; - const props = { + const defaultProps = { illustrationPath: 'illustrations/pending_job_empty.svg', illustrationSizeClass: 'svg-430', title: 'This job has not started yet', @@ -14,100 +12,107 @@ describe('Empty State', () => { variablesSettingsUrl: '', }; + const createWrapper = props => { + wrapper = mount(EmptyState, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + const content = 'This job is in pending state and is waiting to be picked by a runner'; + const findEmptyStateImage = () => wrapper.find('img'); + const findTitle = () => wrapper.find('[data-testid="job-empty-state-title"]'); + const findContent = () => wrapper.find('[data-testid="job-empty-state-content"]'); + const findAction = () => wrapper.find('[data-testid="job-empty-state-action"]'); + const findManualVarsForm = () => wrapper.find('[data-testid="manual-vars-form"]'); + afterEach(() => { - vm.$destroy(); + if (wrapper?.destroy) { + wrapper.destroy(); + wrapper = null; + } }); describe('renders image and title', () => { beforeEach(() => { - vm = mountComponent(Component, { - ...props, - content, - }); + createWrapper(); }); - it('renders img with provided path and size', () => { - expect(vm.$el.querySelector('img').getAttribute('src')).toEqual(props.illustrationPath); - expect(vm.$el.querySelector('.svg-content').classList).toContain(props.illustrationSizeClass); + it('renders empty state image', () => { + expect(findEmptyStateImage().exists()).toBe(true); }); it('renders provided title', () => { - expect(vm.$el.querySelector('.js-job-empty-state-title').textContent.trim()).toEqual( - props.title, - ); + expect( + findTitle() + .text() + .trim(), + ).toBe(defaultProps.title); }); }); describe('with content', () => { - it('renders content', () => { - vm = mountComponent(Component, { - ...props, - content, - }); + beforeEach(() => { + createWrapper({ content }); + }); - expect(vm.$el.querySelector('.js-job-empty-state-content').textContent.trim()).toEqual( - content, - ); + it('renders content', () => { + expect( + findContent() + .text() + .trim(), + ).toBe(content); }); }); describe('without content', () => { - it('does not render content', () => { - vm = mountComponent(Component, { - ...props, - }); + beforeEach(() => { + createWrapper(); + }); - expect(vm.$el.querySelector('.js-job-empty-state-content')).toBeNull(); + it('does not render content', () => { + expect(findContent().exists()).toBe(false); }); }); describe('with action', () => { - it('renders action', () => { - vm = mountComponent(Component, { - ...props, - content, + beforeEach(() => { + createWrapper({ action: { path: 'runner', button_title: 'Check runner', method: 'post', }, }); + }); - expect(vm.$el.querySelector('.js-job-empty-state-action').getAttribute('href')).toEqual( - 'runner', - ); + it('renders action', () => { + expect(findAction().attributes('href')).toBe('runner'); }); }); describe('without action', () => { - it('does not render action', () => { - vm = mountComponent(Component, { - ...props, - content, + beforeEach(() => { + createWrapper({ action: null, }); + }); - expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull(); + it('does not render action', () => { + expect(findAction().exists()).toBe(false); }); - }); - describe('without playbale action', () => { it('does not render manual variables form', () => { - vm = mountComponent(Component, { - ...props, - content, - }); - - expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull(); + expect(findManualVarsForm().exists()).toBe(false); }); }); - describe('with playbale action and not scheduled job', () => { + describe('with playable action and not scheduled job', () => { beforeEach(() => { - vm = mountComponent(Component, { - ...props, + createWrapper({ content, playable: true, scheduled: false, @@ -120,22 +125,25 @@ describe('Empty State', () => { }); it('renders manual variables form', () => { - expect(vm.$el.querySelector('.js-manual-vars-form')).not.toBeNull(); + expect(findManualVarsForm().exists()).toBe(true); }); it('does not render the empty state action', () => { - expect(vm.$el.querySelector('.js-job-empty-state-action')).toBeNull(); + expect(findAction().exists()).toBe(false); }); }); - describe('with playbale action and scheduled job', () => { - it('does not render manual variables form', () => { - vm = mountComponent(Component, { - ...props, + describe('with playable action and scheduled job', () => { + beforeEach(() => { + createWrapper({ + playable: true, + scheduled: true, content, }); + }); - expect(vm.$el.querySelector('.js-manual-vars-form')).toBeNull(); + it('does not render manual variables form', () => { + expect(findManualVarsForm().exists()).toBe(false); }); }); }); diff --git a/spec/frontend/jobs/components/job_app_spec.js b/spec/frontend/jobs/components/job_app_spec.js index d0b3d4f6247..e9ecafcd4c3 100644 --- a/spec/frontend/jobs/components/job_app_spec.js +++ b/spec/frontend/jobs/components/job_app_spec.js @@ -1,12 +1,19 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { getJSONFixture } from 'helpers/fixtures'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import JobApp from '~/jobs/components/job_app.vue'; +import Sidebar from '~/jobs/components/sidebar.vue'; +import StuckBlock from '~/jobs/components/stuck_block.vue'; +import UnmetPrerequisitesBlock from '~/jobs/components/unmet_prerequisites_block.vue'; +import EnvironmentsBlock from '~/jobs/components/environments_block.vue'; +import ErasedBlock from '~/jobs/components/erased_block.vue'; +import EmptyState from '~/jobs/components/empty_state.vue'; import createStore from '~/jobs/store'; import job from '../mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Job App', () => { const localVue = createLocalVue(); @@ -55,6 +62,26 @@ describe('Job App', () => { .then(() => wrapper.vm.$nextTick()); }; + const findLoadingComponent = () => wrapper.find(GlLoadingIcon); + const findSidebar = () => wrapper.find(Sidebar); + const findJobContent = () => wrapper.find('[data-testid="job-content"'); + const findStuckBlockComponent = () => wrapper.find(StuckBlock); + const findStuckBlockWithTags = () => wrapper.find('[data-testid="job-stuck-with-tags"'); + const findStuckBlockNoActiveRunners = () => + wrapper.find('[data-testid="job-stuck-no-active-runners"'); + const findFailedJobComponent = () => wrapper.find(UnmetPrerequisitesBlock); + const findEnvironmentsBlockComponent = () => wrapper.find(EnvironmentsBlock); + const findErasedBlock = () => wrapper.find(ErasedBlock); + const findArchivedJob = () => wrapper.find('[data-testid="archived-job"]'); + const findEmptyState = () => wrapper.find(EmptyState); + const findJobNewIssueLink = () => wrapper.find('[data-testid="job-new-issue"]'); + const findJobEmptyStateTitle = () => wrapper.find('[data-testid="job-empty-state-title"]'); + const findJobTraceScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); + const findJobTraceScrollBottom = () => + wrapper.find('[data-testid="job-controller-scroll-bottom"]'); + const findJobTraceController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); + const findJobTraceEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); + beforeEach(() => { mock = new MockAdapter(axios); store = createStore(); @@ -72,9 +99,9 @@ describe('Job App', () => { }); it('renders loading icon', () => { - expect(wrapper.find('.js-job-loading').exists()).toBe(true); - expect(wrapper.find('.js-job-sidebar').exists()).toBe(false); - expect(wrapper.find('.js-job-content').exists()).toBe(false); + expect(findLoadingComponent().exists()).toBe(true); + expect(findSidebar().exists()).toBe(false); + expect(findJobContent().exists()).toBe(false); }); }); @@ -115,7 +142,7 @@ describe('Job App', () => { }); it('should render new issue link', () => { - expect(wrapper.find('.js-new-issue').attributes('href')).toEqual(job.new_issue_path); + expect(findJobNewIssueLink().attributes('href')).toEqual(job.new_issue_path); }); }); @@ -134,7 +161,7 @@ describe('Job App', () => { }); describe('stuck block', () => { - describe('without active runners availabl', () => { + describe('without active runners available', () => { it('renders stuck block when there are no runners', () => setupAndMount({ jobData: { @@ -153,8 +180,8 @@ describe('Job App', () => { tags: [], }, }).then(() => { - expect(wrapper.find('.js-job-stuck').exists()).toBe(true); - expect(wrapper.find('.js-job-stuck .js-stuck-no-active-runner').exists()).toBe(true); + expect(findStuckBlockComponent().exists()).toBe(true); + expect(findStuckBlockNoActiveRunners().exists()).toBe(true); })); }); @@ -176,8 +203,8 @@ describe('Job App', () => { }, }, }).then(() => { - expect(wrapper.find('.js-job-stuck').text()).toContain(job.tags[0]); - expect(wrapper.find('.js-job-stuck .js-stuck-with-tags').exists()).toBe(true); + expect(findStuckBlockComponent().text()).toContain(job.tags[0]); + expect(findStuckBlockWithTags().exists()).toBe(true); })); }); @@ -199,8 +226,8 @@ describe('Job App', () => { }, }, }).then(() => { - expect(wrapper.find('.js-job-stuck').text()).toContain(job.tags[0]); - expect(wrapper.find('.js-job-stuck .js-stuck-with-tags').exists()).toBe(true); + expect(findStuckBlockComponent().text()).toContain(job.tags[0]); + expect(findStuckBlockWithTags().exists()).toBe(true); })); }); @@ -210,7 +237,7 @@ describe('Job App', () => { runners: { available: true }, }, }).then(() => { - expect(wrapper.find('.js-job-stuck').exists()).toBe(false); + expect(findStuckBlockComponent().exists()).toBe(false); })); }); @@ -239,7 +266,7 @@ describe('Job App', () => { tags: [], }, }).then(() => { - expect(wrapper.find('.js-job-failed').exists()).toBe(true); + expect(findFailedJobComponent().exists()).toBe(true); })); }); @@ -255,12 +282,12 @@ describe('Job App', () => { }, }, }).then(() => { - expect(wrapper.find('.js-job-environment').exists()).toBe(true); + expect(findEnvironmentsBlockComponent().exists()).toBe(true); })); it('does not render environment block when job has environment', () => setupAndMount().then(() => { - expect(wrapper.find('.js-job-environment').exists()).toBe(false); + expect(findEnvironmentsBlockComponent().exists()).toBe(false); })); }); @@ -275,7 +302,7 @@ describe('Job App', () => { erased_at: '2016-11-07T11:11:16.525Z', }, }).then(() => { - expect(wrapper.find('.js-job-erased-block').exists()).toBe(true); + expect(findErasedBlock().exists()).toBe(true); })); it('does not render erased block when `erased` is false', () => @@ -284,7 +311,7 @@ describe('Job App', () => { erased_at: null, }, }).then(() => { - expect(wrapper.find('.js-job-erased-block').exists()).toBe(false); + expect(findErasedBlock().exists()).toBe(false); })); }); @@ -313,7 +340,7 @@ describe('Job App', () => { }, }, }).then(() => { - expect(wrapper.find('.js-job-empty-state').exists()).toBe(true); + expect(findEmptyState().exists()).toBe(true); })); it('does not render empty state when job does not have trace but it is running', () => @@ -329,12 +356,12 @@ describe('Job App', () => { }, }, }).then(() => { - expect(wrapper.find('.js-job-empty-state').exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); })); it('does not render empty state when job has trace but it is not running', () => setupAndMount({ jobData: { has_trace: true } }).then(() => { - expect(wrapper.find('.js-job-empty-state').exists()).toBe(false); + expect(findEmptyState().exists()).toBe(false); })); it('displays remaining time for a delayed job', () => { @@ -345,9 +372,9 @@ describe('Job App', () => { () => new Date(delayedJobFixture.scheduled_at).getTime() - oneHourInMilliseconds, ); return setupAndMount({ jobData: delayedJobFixture }).then(() => { - expect(wrapper.find('.js-job-empty-state').exists()).toBe(true); + expect(findEmptyState().exists()).toBe(true); - const title = wrapper.find('.js-job-empty-state-title').text(); + const title = findJobEmptyStateTitle().text(); expect(title).toEqual('This is a delayed job to run in 01:00:00'); }); @@ -386,7 +413,7 @@ describe('Job App', () => { beforeEach(() => setupAndMount({ jobData: { archived: true } })); it('renders warning about job being archived', () => { - expect(wrapper.find('.js-archived-job ').exists()).toBe(true); + expect(findArchivedJob().exists()).toBe(true); }); }); @@ -394,7 +421,7 @@ describe('Job App', () => { beforeEach(() => setupAndMount()); it('does not warning about job being archived', () => { - expect(wrapper.find('.js-archived-job ').exists()).toBe(false); + expect(findArchivedJob().exists()).toBe(false); }); }); @@ -413,16 +440,16 @@ describe('Job App', () => { ); it('should render scroll buttons', () => { - expect(wrapper.find('.js-scroll-top').exists()).toBe(true); - expect(wrapper.find('.js-scroll-bottom').exists()).toBe(true); + expect(findJobTraceScrollTop().exists()).toBe(true); + expect(findJobTraceScrollBottom().exists()).toBe(true); }); it('should render link to raw ouput', () => { - expect(wrapper.find('.js-raw-link-controller').exists()).toBe(true); + expect(findJobTraceController().exists()).toBe(true); }); it('should render link to erase job', () => { - expect(wrapper.find('.js-erase-link').exists()).toBe(true); + expect(findJobTraceEraseLink().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/job_log_controllers_spec.js b/spec/frontend/jobs/components/job_log_controllers_spec.js index 04f20811601..233cef05622 100644 --- a/spec/frontend/jobs/components/job_log_controllers_spec.js +++ b/spec/frontend/jobs/components/job_log_controllers_spec.js @@ -1,16 +1,17 @@ -import Vue from 'vue'; -import component from '~/jobs/components/job_log_controllers.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import { mount } from '@vue/test-utils'; +import JobLogControllers from '~/jobs/components/job_log_controllers.vue'; describe('Job log controllers', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; afterEach(() => { - vm.$destroy(); + if (wrapper?.destroy) { + wrapper.destroy(); + wrapper = null; + } }); - const props = { + const defaultProps = { rawPath: '/raw', erasePath: '/erase', size: 511952, @@ -20,70 +21,80 @@ describe('Job log controllers', () => { isTraceSizeVisible: true, }; + const createWrapper = props => { + wrapper = mount(JobLogControllers, { + propsData: { + ...defaultProps, + ...props, + }, + }); + }; + + const findTruncatedInfo = () => wrapper.find('[data-testid="log-truncated-info"]'); + const findRawLink = () => wrapper.find('[data-testid="raw-link"]'); + const findRawLinkController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); + const findEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); + const findScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); + const findScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); + describe('Truncate information', () => { describe('with isTraceSizeVisible', () => { beforeEach(() => { - vm = mountComponent(Component, props); + createWrapper(); }); it('renders size information', () => { - expect(vm.$el.querySelector('.js-truncated-info').textContent).toContain('499.95 KiB'); + expect(findTruncatedInfo().text()).toMatch('499.95 KiB'); }); it('renders link to raw trace', () => { - expect(vm.$el.querySelector('.js-raw-link').getAttribute('href')).toEqual('/raw'); + expect(findRawLink().attributes('href')).toBe(defaultProps.rawPath); }); }); }); describe('links section', () => { describe('with raw trace path', () => { - it('renders raw trace link', () => { - vm = mountComponent(Component, props); + beforeEach(() => { + createWrapper(); + }); - expect(vm.$el.querySelector('.js-raw-link-controller').getAttribute('href')).toEqual( - '/raw', - ); + it('renders raw trace link', () => { + expect(findRawLinkController().attributes('href')).toBe(defaultProps.rawPath); }); }); describe('without raw trace path', () => { - it('does not render raw trace link', () => { - vm = mountComponent(Component, { - erasePath: '/erase', - size: 511952, - isScrollTopDisabled: true, - isScrollBottomDisabled: true, - isScrollingDown: false, - isTraceSizeVisible: true, + beforeEach(() => { + createWrapper({ + rawPath: null, }); + }); - expect(vm.$el.querySelector('.js-raw-link-controller')).toBeNull(); + it('does not render raw trace link', () => { + expect(findRawLinkController().exists()).toBe(false); }); }); describe('when is erasable', () => { beforeEach(() => { - vm = mountComponent(Component, props); + createWrapper(); }); it('renders erase job link', () => { - expect(vm.$el.querySelector('.js-erase-link')).not.toBeNull(); + expect(findEraseLink().exists()).toBe(true); }); }); describe('when it is not erasable', () => { - it('does not render erase button', () => { - vm = mountComponent(Component, { - rawPath: '/raw', - size: 511952, - isScrollTopDisabled: true, - isScrollBottomDisabled: true, - isScrollingDown: false, - isTraceSizeVisible: true, + beforeEach(() => { + createWrapper({ + erasePath: null, }); + }); - expect(vm.$el.querySelector('.js-erase-link')).toBeNull(); + it('does not render erase button', () => { + expect(findEraseLink().exists()).toBe(false); }); }); }); @@ -92,45 +103,39 @@ describe('Job log controllers', () => { describe('scroll top button', () => { describe('when user can scroll top', () => { beforeEach(() => { - vm = mountComponent(Component, props); + createWrapper({ + isScrollTopDisabled: false, + }); }); - it('renders enabled scroll top button', () => { - expect(vm.$el.querySelector('.js-scroll-top').getAttribute('disabled')).toBeNull(); - }); + it('emits scrollJobLogTop event on click', async () => { + findScrollTop().trigger('click'); - it('emits scrollJobLogTop event on click', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.$el.querySelector('.js-scroll-top').click(); + await wrapper.vm.$nextTick(); - expect(vm.$emit).toHaveBeenCalledWith('scrollJobLogTop'); + expect(wrapper.emitted().scrollJobLogTop).toHaveLength(1); }); }); describe('when user can not scroll top', () => { beforeEach(() => { - vm = mountComponent(Component, { - rawPath: '/raw', - erasePath: '/erase', - size: 511952, + createWrapper({ isScrollTopDisabled: true, isScrollBottomDisabled: false, isScrollingDown: false, - isTraceSizeVisible: true, }); }); it('renders disabled scroll top button', () => { - expect(vm.$el.querySelector('.js-scroll-top').getAttribute('disabled')).toEqual( - 'disabled', - ); + expect(findScrollTop().attributes('disabled')).toBe('disabled'); }); - it('does not emit scrollJobLogTop event on click', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.$el.querySelector('.js-scroll-top').click(); + it('does not emit scrollJobLogTop event on click', async () => { + findScrollTop().trigger('click'); - expect(vm.$emit).not.toHaveBeenCalledWith('scrollJobLogTop'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().scrollJobLogTop).toBeUndefined(); }); }); }); @@ -138,69 +143,61 @@ describe('Job log controllers', () => { describe('scroll bottom button', () => { describe('when user can scroll bottom', () => { beforeEach(() => { - vm = mountComponent(Component, props); + createWrapper(); }); - it('renders enabled scroll bottom button', () => { - expect(vm.$el.querySelector('.js-scroll-bottom').getAttribute('disabled')).toBeNull(); - }); + it('emits scrollJobLogBottom event on click', async () => { + findScrollBottom().trigger('click'); - it('emits scrollJobLogBottom event on click', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.$el.querySelector('.js-scroll-bottom').click(); + await wrapper.vm.$nextTick(); - expect(vm.$emit).toHaveBeenCalledWith('scrollJobLogBottom'); + expect(wrapper.emitted().scrollJobLogBottom).toHaveLength(1); }); }); describe('when user can not scroll bottom', () => { beforeEach(() => { - vm = mountComponent(Component, { - rawPath: '/raw', - erasePath: '/erase', - size: 511952, + createWrapper({ isScrollTopDisabled: false, isScrollBottomDisabled: true, isScrollingDown: false, - isTraceSizeVisible: true, }); }); it('renders disabled scroll bottom button', () => { - expect(vm.$el.querySelector('.js-scroll-bottom').getAttribute('disabled')).toEqual( - 'disabled', - ); + expect(findScrollBottom().attributes('disabled')).toEqual('disabled'); }); - it('does not emit scrollJobLogBottom event on click', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.$el.querySelector('.js-scroll-bottom').click(); + it('does not emit scrollJobLogBottom event on click', async () => { + findScrollBottom().trigger('click'); - expect(vm.$emit).not.toHaveBeenCalledWith('scrollJobLogBottom'); + await wrapper.vm.$nextTick(); + + expect(wrapper.emitted().scrollJobLogBottom).toBeUndefined(); }); }); describe('while isScrollingDown is true', () => { - it('renders animate class for the scroll down button', () => { - vm = mountComponent(Component, props); + beforeEach(() => { + createWrapper(); + }); - expect(vm.$el.querySelector('.js-scroll-bottom').className).toContain('animate'); + it('renders animate class for the scroll down button', () => { + expect(findScrollBottom().classes()).toContain('animate'); }); }); describe('while isScrollingDown is false', () => { - it('does not render animate class for the scroll down button', () => { - vm = mountComponent(Component, { - rawPath: '/raw', - erasePath: '/erase', - size: 511952, + beforeEach(() => { + createWrapper({ isScrollTopDisabled: true, isScrollBottomDisabled: false, isScrollingDown: false, - isTraceSizeVisible: true, }); + }); - expect(vm.$el.querySelector('.js-scroll-bottom').className).not.toContain('animate'); + it('does not render animate class for the scroll down button', () => { + expect(findScrollBottom().classes()).not.toContain('animate'); }); }); }); diff --git a/spec/frontend/jobs/components/log/mock_data.js b/spec/frontend/jobs/components/log/mock_data.js index a6a767f7921..eb8c4fe8bc9 100644 --- a/spec/frontend/jobs/components/log/mock_data.js +++ b/spec/frontend/jobs/components/log/mock_data.js @@ -34,7 +34,7 @@ export const utilsMockData = [ content: [ { text: - 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.27-lfs-2.9-chrome-83-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34', + 'Using Docker executor with image dev.gitlab.org:5005/gitlab/gitlab-build-images:ruby-2.6.6-golang-1.14-git-2.28-lfs-2.9-chrome-84-node-12.x-yarn-1.21-postgresql-11-graphicsmagick-1.3.34', }, ], section: 'prepare-executor', diff --git a/spec/frontend/jobs/components/sidebar_spec.js b/spec/frontend/jobs/components/sidebar_spec.js index 0c8e2dc3aef..48788df0c93 100644 --- a/spec/frontend/jobs/components/sidebar_spec.js +++ b/spec/frontend/jobs/components/sidebar_spec.js @@ -59,11 +59,13 @@ describe('Sidebar details block', () => { describe('actions', () => { it('should render link to new issue', () => { - expect(vm.$el.querySelector('.js-new-issue').getAttribute('href')).toEqual( + expect(vm.$el.querySelector('[data-testid="job-new-issue"]').getAttribute('href')).toEqual( job.new_issue_path, ); - expect(vm.$el.querySelector('.js-new-issue').textContent.trim()).toEqual('New issue'); + expect(vm.$el.querySelector('[data-testid="job-new-issue"]').textContent.trim()).toEqual( + 'New issue', + ); }); it('should render link to retry job', () => { diff --git a/spec/frontend/jobs/components/stuck_block_spec.js b/spec/frontend/jobs/components/stuck_block_spec.js index c320793b2be..926286bf75a 100644 --- a/spec/frontend/jobs/components/stuck_block_spec.js +++ b/spec/frontend/jobs/components/stuck_block_spec.js @@ -1,31 +1,50 @@ -import Vue from 'vue'; -import component from '~/jobs/components/stuck_block.vue'; -import mountComponent from '../../helpers/vue_mount_component_helper'; +import { GlBadge, GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import StuckBlock from '~/jobs/components/stuck_block.vue'; describe('Stuck Block Job component', () => { - const Component = Vue.extend(component); - let vm; + let wrapper; afterEach(() => { - vm.$destroy(); + if (wrapper?.destroy) { + wrapper.destroy(); + wrapper = null; + } }); + const createWrapper = props => { + wrapper = shallowMount(StuckBlock, { + propsData: { + ...props, + }, + }); + }; + + const tags = ['docker', 'gitlab-org']; + + const findStuckNoActiveRunners = () => + wrapper.find('[data-testid="job-stuck-no-active-runners"]'); + const findStuckNoRunners = () => wrapper.find('[data-testid="job-stuck-no-runners"]'); + const findStuckWithTags = () => wrapper.find('[data-testid="job-stuck-with-tags"]'); + const findRunnerPathLink = () => wrapper.find(GlLink); + const findAllBadges = () => wrapper.findAll(GlBadge); + describe('with no runners for project', () => { beforeEach(() => { - vm = mountComponent(Component, { + createWrapper({ hasNoRunnersForProject: true, runnersPath: '/root/project/runners#js-runners-settings', }); }); it('renders only information about project not having runners', () => { - expect(vm.$el.querySelector('.js-stuck-no-runners')).not.toBeNull(); - expect(vm.$el.querySelector('.js-stuck-with-tags')).toBeNull(); - expect(vm.$el.querySelector('.js-stuck-no-active-runner')).toBeNull(); + expect(findStuckNoRunners().exists()).toBe(true); + expect(findStuckWithTags().exists()).toBe(false); + expect(findStuckNoActiveRunners().exists()).toBe(false); }); it('renders link to runners page', () => { - expect(vm.$el.querySelector('.js-runners-path').getAttribute('href')).toEqual( + expect(findRunnerPathLink().attributes('href')).toBe( '/root/project/runners#js-runners-settings', ); }); @@ -33,26 +52,27 @@ describe('Stuck Block Job component', () => { describe('with tags', () => { beforeEach(() => { - vm = mountComponent(Component, { + createWrapper({ hasNoRunnersForProject: false, - tags: ['docker', 'gitlab-org'], + tags, runnersPath: '/root/project/runners#js-runners-settings', }); }); it('renders information about the tags not being set', () => { - expect(vm.$el.querySelector('.js-stuck-no-runners')).toBeNull(); - expect(vm.$el.querySelector('.js-stuck-with-tags')).not.toBeNull(); - expect(vm.$el.querySelector('.js-stuck-no-active-runner')).toBeNull(); + expect(findStuckWithTags().exists()).toBe(true); + expect(findStuckNoActiveRunners().exists()).toBe(false); + expect(findStuckNoRunners().exists()).toBe(false); }); it('renders tags', () => { - expect(vm.$el.textContent).toContain('docker'); - expect(vm.$el.textContent).toContain('gitlab-org'); + findAllBadges().wrappers.forEach((badgeElt, index) => { + return expect(badgeElt.text()).toBe(tags[index]); + }); }); it('renders link to runners page', () => { - expect(vm.$el.querySelector('.js-runners-path').getAttribute('href')).toEqual( + expect(findRunnerPathLink().attributes('href')).toBe( '/root/project/runners#js-runners-settings', ); }); @@ -60,20 +80,20 @@ describe('Stuck Block Job component', () => { describe('without active runners', () => { beforeEach(() => { - vm = mountComponent(Component, { + createWrapper({ hasNoRunnersForProject: false, runnersPath: '/root/project/runners#js-runners-settings', }); }); it('renders information about project not having runners', () => { - expect(vm.$el.querySelector('.js-stuck-no-runners')).toBeNull(); - expect(vm.$el.querySelector('.js-stuck-with-tags')).toBeNull(); - expect(vm.$el.querySelector('.js-stuck-no-active-runner')).not.toBeNull(); + expect(findStuckNoActiveRunners().exists()).toBe(true); + expect(findStuckNoRunners().exists()).toBe(false); + expect(findStuckWithTags().exists()).toBe(false); }); it('renders link to runners page', () => { - expect(vm.$el.querySelector('.js-runners-path').getAttribute('href')).toEqual( + expect(findRunnerPathLink().attributes('href')).toBe( '/root/project/runners#js-runners-settings', ); }); diff --git a/spec/frontend/labels_select_spec.js b/spec/frontend/labels_select_spec.js index 8b08eb9e124..cbc9a923f8b 100644 --- a/spec/frontend/labels_select_spec.js +++ b/spec/frontend/labels_select_spec.js @@ -29,7 +29,7 @@ const mockScopedLabels2 = [ title: 'Foo::Bar2', description: 'Foobar2', color: '#FFFFFF', - text_color: '#000000', + text_color: '#333333', }, ]; @@ -61,10 +61,11 @@ describe('LabelsSelect', () => { expect($labelEl.find('a').attr('title')).toBe(label.description); }); - it('generated label item template has correct label styles', () => { + it('generated label item template has correct label styles and classes', () => { expect($labelEl.find('span.gl-label-text').attr('style')).toBe( - `background-color: ${label.color}; color: ${label.text_color};`, + `background-color: ${label.color};`, ); + expect($labelEl.find('span.gl-label-text')).toHaveClass('gl-label-text-light'); }); it('generated label item has a gl-label-text class', () => { @@ -100,16 +101,12 @@ describe('LabelsSelect', () => { expect($labelEl.find('a').attr('data-html')).toBe('true'); }); - it('generated label item template has correct label styles', () => { + it('generated label item template has correct label styles and classes', () => { expect($labelEl.find('span.gl-label-text').attr('style')).toBe( - `background-color: ${label.color}; color: ${label.text_color};`, + `background-color: ${label.color};`, ); - expect( - $labelEl - .find('span.gl-label-text') - .last() - .attr('style'), - ).toBe(`color: ${label.color};`); + expect($labelEl.find('span.gl-label-text')).toHaveClass('gl-label-text-light'); + expect($labelEl.find('span.gl-label-text').last()).not.toHaveClass('gl-label-text-light'); }); it('generated label item has a badge class', () => { @@ -131,16 +128,12 @@ describe('LabelsSelect', () => { ); }); - it('generated label item template has correct label styles', () => { + it('generated label item template has correct label styles and classes', () => { expect($labelEl.find('span.gl-label-text').attr('style')).toBe( - `background-color: ${label.color}; color: ${label.text_color};`, + `background-color: ${label.color};`, ); - expect( - $labelEl - .find('span.gl-label-text') - .last() - .attr('style'), - ).toBe(`color: ${label.text_color};`); + expect($labelEl.find('span.gl-label-text')).toHaveClass('gl-label-text-dark'); + expect($labelEl.find('span.gl-label-text').last()).toHaveClass('gl-label-text-dark'); }); }); }); diff --git a/spec/frontend/lazy_loader_spec.js b/spec/frontend/lazy_loader_spec.js index 79a49aedf37..5eb09bc2359 100644 --- a/spec/frontend/lazy_loader_spec.js +++ b/spec/frontend/lazy_loader_spec.js @@ -1,8 +1,8 @@ import { noop } from 'lodash'; -import LazyLoader from '~/lazy_loader'; import { TEST_HOST } from 'helpers/test_constants'; -import waitForPromises from './helpers/wait_for_promises'; import { useMockMutationObserver, useMockIntersectionObserver } from 'helpers/mock_dom_observer'; +import LazyLoader from '~/lazy_loader'; +import waitForPromises from './helpers/wait_for_promises'; const execImmediately = callback => { callback(); @@ -45,10 +45,24 @@ describe('LazyLoader', () => { return newImg; }; + const mockLoadEvent = () => { + const addEventListener = window.addEventListener.bind(window); + + jest.spyOn(window, 'addEventListener').mockImplementation((event, callback) => { + if (event === 'load') { + callback(); + } else { + addEventListener(event, callback); + } + }); + }; + beforeEach(() => { jest.spyOn(window, 'requestAnimationFrame').mockImplementation(execImmediately); jest.spyOn(window, 'requestIdleCallback').mockImplementation(execImmediately); jest.spyOn(LazyLoader, 'loadImage'); + + mockLoadEvent(); }); afterEach(() => { diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 585f0de9cc3..effc446d846 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1,5 +1,5 @@ -import * as commonUtils from '~/lib/utils/common_utils'; import $ from 'jquery'; +import * as commonUtils from '~/lib/utils/common_utils'; describe('common_utils', () => { describe('parseUrl', () => { diff --git a/spec/frontend/lib/utils/csrf_token_spec.js b/spec/frontend/lib/utils/csrf_token_spec.js index 1b98ef126e9..55dd29571c0 100644 --- a/spec/frontend/lib/utils/csrf_token_spec.js +++ b/spec/frontend/lib/utils/csrf_token_spec.js @@ -1,5 +1,5 @@ -import csrf from '~/lib/utils/csrf'; import { setHTMLFixture } from 'helpers/fixtures'; +import csrf from '~/lib/utils/csrf'; describe('csrf', () => { let testContext; diff --git a/spec/frontend/lib/utils/datetime_utility_spec.js b/spec/frontend/lib/utils/datetime_utility_spec.js index adf5c312149..9eb5587e83c 100644 --- a/spec/frontend/lib/utils/datetime_utility_spec.js +++ b/spec/frontend/lib/utils/datetime_utility_spec.js @@ -1,6 +1,6 @@ -import { __, s__ } from '~/locale'; import $ from 'jquery'; import timezoneMock from 'timezone-mock'; +import { __, s__ } from '~/locale'; import '~/commons/bootstrap'; import * as datetimeUtility from '~/lib/utils/datetime_utility'; @@ -628,3 +628,28 @@ describe('localTimeAgo', () => { expect(element.getAttribute('title')).toBe(title); }); }); + +describe('dateFromParams', () => { + it('returns the expected date object', () => { + const expectedDate = new Date('2019-07-17T00:00:00.000Z'); + const date = datetimeUtility.dateFromParams(2019, 6, 17); + + expect(date.getYear()).toBe(expectedDate.getYear()); + expect(date.getMonth()).toBe(expectedDate.getMonth()); + expect(date.getDate()).toBe(expectedDate.getDate()); + }); +}); + +describe('differenceInSeconds', () => { + const startDateTime = new Date('2019-07-17T00:00:00.000Z'); + + it.each` + startDate | endDate | expected + ${startDateTime} | ${new Date('2019-07-17T00:00:00.000Z')} | ${0} + ${startDateTime} | ${new Date('2019-07-17T12:00:00.000Z')} | ${43200} + ${startDateTime} | ${new Date('2019-07-18T00:00:00.000Z')} | ${86400} + ${new Date('2019-07-18T00:00:00.000Z')} | ${startDateTime} | ${-86400} + `('returns $expected for $endDate - $startDate', ({ startDate, endDate, expected }) => { + expect(datetimeUtility.differenceInSeconds(startDate, endDate)).toBe(expected); + }); +}); diff --git a/spec/frontend/lib/utils/poll_spec.js b/spec/frontend/lib/utils/poll_spec.js index 5ee9738ebf3..135c752b5cb 100644 --- a/spec/frontend/lib/utils/poll_spec.js +++ b/spec/frontend/lib/utils/poll_spec.js @@ -1,6 +1,6 @@ +import waitForPromises from 'helpers/wait_for_promises'; import Poll from '~/lib/utils/poll'; import { successCodes } from '~/lib/utils/http_status'; -import waitForPromises from 'helpers/wait_for_promises'; describe('Poll', () => { let callbacks; @@ -128,6 +128,35 @@ describe('Poll', () => { }); }); + describe('with delayed initial request', () => { + it('delays the first request', async done => { + mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); + + const Polling = new Poll({ + resource: service, + method: 'fetch', + data: { page: 1 }, + successCallback: callbacks.success, + errorCallback: callbacks.error, + }); + + Polling.makeDelayedRequest(1); + + expect(Polling.timeoutID).toBeTruthy(); + + waitForAllCallsToFinish(2, () => { + Polling.stop(); + + expect(service.fetch.mock.calls).toHaveLength(2); + expect(service.fetch).toHaveBeenCalledWith({ page: 1 }); + expect(callbacks.success).toHaveBeenCalled(); + expect(callbacks.error).not.toHaveBeenCalled(); + + done(); + }); + }); + }); + describe('stop', () => { it('stops polling when method is called', done => { mockServiceCall({ status: 200, headers: { 'poll-interval': 1 } }); diff --git a/spec/frontend/lib/utils/poll_until_complete_spec.js b/spec/frontend/lib/utils/poll_until_complete_spec.js index 15602b87b9c..c1df30756fd 100644 --- a/spec/frontend/lib/utils/poll_until_complete_spec.js +++ b/spec/frontend/lib/utils/poll_until_complete_spec.js @@ -1,8 +1,8 @@ import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import pollUntilComplete from '~/lib/utils/poll_until_complete'; import httpStatusCodes from '~/lib/utils/http_status'; -import { TEST_HOST } from 'helpers/test_constants'; const endpoint = `${TEST_HOST}/foo`; const mockData = 'mockData'; diff --git a/spec/frontend/lib/utils/sticky_spec.js b/spec/frontend/lib/utils/sticky_spec.js index 4ad68cc9ff6..01e8fe777af 100644 --- a/spec/frontend/lib/utils/sticky_spec.js +++ b/spec/frontend/lib/utils/sticky_spec.js @@ -1,5 +1,5 @@ -import { isSticky } from '~/lib/utils/sticky'; import { setHTMLFixture } from 'helpers/fixtures'; +import { isSticky } from '~/lib/utils/sticky'; const TEST_OFFSET_TOP = 500; diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index e769580b587..a13ac3778cf 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -160,6 +160,118 @@ describe('URL utility', () => { 'https://host/path?op=%2B&foo=bar', ); }); + + describe('with spread array option', () => { + const spreadArrayOptions = { spreadArrays: true }; + + it('maintains multiple values', () => { + expect(mergeUrlParams({}, '?array[]=foo&array[]=bar', spreadArrayOptions)).toBe( + '?array[]=foo&array[]=bar', + ); + }); + + it('overrides multiple values with one', () => { + expect( + mergeUrlParams({ array: ['baz'] }, '?array[]=foo&array[]=bar', spreadArrayOptions), + ).toBe('?array[]=baz'); + }); + it('removes existing params', () => { + expect( + mergeUrlParams({ array: null }, '?array[]=foo&array[]=bar', spreadArrayOptions), + ).toBe(''); + }); + it('removes existing params and keeps others', () => { + expect( + mergeUrlParams( + { array: null }, + '?array[]=foo&array[]=bar&other=quis', + spreadArrayOptions, + ), + ).toBe('?other=quis'); + }); + it('removes existing params along others', () => { + expect( + mergeUrlParams( + { array: null, other: 'quis' }, + '?array[]=foo&array[]=bar', + spreadArrayOptions, + ), + ).toBe('?other=quis'); + }); + it('handles empty arrays along other parameters', () => { + expect(mergeUrlParams({ array: [], other: 'quis' }, '?array=baz', spreadArrayOptions)).toBe( + '?array[]=&other=quis', + ); + }); + it('handles multiple values along other parameters', () => { + expect( + mergeUrlParams( + { array: ['foo', 'bar'], other: 'quis' }, + '?array=baz', + spreadArrayOptions, + ), + ).toBe('?array[]=foo&array[]=bar&other=quis'); + }); + it('handles array values with encoding', () => { + expect( + mergeUrlParams({ array: ['foo+', 'bar,baz'] }, '?array[]=%2Fbaz', spreadArrayOptions), + ).toBe('?array[]=foo%2B&array[]=bar%2Cbaz'); + }); + it('handles multiple arrays', () => { + expect( + mergeUrlParams( + { array1: ['foo+', 'bar,baz'], array2: ['quis', 'quux'] }, + '?array1[]=%2Fbaz', + spreadArrayOptions, + ), + ).toBe('?array1[]=foo%2B&array1[]=bar%2Cbaz&array2[]=quis&array2[]=quux'); + }); + }); + + describe('without spread array option', () => { + it('maintains multiple values', () => { + expect(mergeUrlParams({}, '?array=foo%2Cbar')).toBe('?array=foo%2Cbar'); + }); + it('overrides multiple values with one', () => { + expect(mergeUrlParams({ array: ['baz'] }, '?array=foo%2Cbar')).toBe('?array=baz'); + }); + it('removes existing params', () => { + expect(mergeUrlParams({ array: null }, '?array=foo%2Cbar')).toBe(''); + }); + it('removes existing params and keeps others', () => { + expect(mergeUrlParams({ array: null }, '?array=foo&array=bar&other=quis')).toBe( + '?other=quis', + ); + }); + it('removes existing params along others', () => { + expect(mergeUrlParams({ array: null, other: 'quis' }, '?array=foo&array=bar')).toBe( + '?other=quis', + ); + }); + it('handles empty arrays along other parameters', () => { + expect(mergeUrlParams({ array: [], other: 'quis' }, '?array=baz')).toBe( + '?array=&other=quis', + ); + }); + it('handles multiple values along other parameters', () => { + expect(mergeUrlParams({ array: ['foo', 'bar'], other: 'quis' }, '?array=baz')).toBe( + '?array=foo%2Cbar&other=quis', + ); + }); + it('handles array values with encoding', () => { + expect(mergeUrlParams({ array: ['foo+', 'bar,baz'] }, '?array=%2Fbaz')).toBe( + '?array=foo%2B%2Cbar%2Cbaz', + ); + }); + it('handles multiple arrays', () => { + expect( + mergeUrlParams( + { array1: ['foo+', 'bar,baz'], array2: ['quis', 'quux'] }, + '?array1=%2Fbaz', + ), + ).toBe('?array1=foo%2B%2Cbar%2Cbaz&array2=quis%2Cquux'); + }); + }); }); describe('removeParams', () => { diff --git a/spec/frontend/locale/index_spec.js b/spec/frontend/locale/index_spec.js index 346ed5182f4..d65d7c195b2 100644 --- a/spec/frontend/locale/index_spec.js +++ b/spec/frontend/locale/index_spec.js @@ -1,6 +1,5 @@ -import { createDateTimeFormat, languageCode } from '~/locale'; - import { setLanguage } from 'helpers/locale_helper'; +import { createDateTimeFormat, languageCode } from '~/locale'; describe('locale', () => { afterEach(() => setLanguage(null)); diff --git a/spec/frontend/logs/components/environment_logs_spec.js b/spec/frontend/logs/components/environment_logs_spec.js index dee62709d81..6421aca684f 100644 --- a/spec/frontend/logs/components/environment_logs_spec.js +++ b/spec/frontend/logs/components/environment_logs_spec.js @@ -1,4 +1,4 @@ -import { GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlSprintf, GlIcon, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import EnvironmentLogs from '~/logs/components/environment_logs.vue'; @@ -124,7 +124,7 @@ describe('EnvironmentLogs', () => { expect(wrapper.isVueInstance()).toBe(true); expect(wrapper.isEmpty()).toBe(false); - expect(findEnvironmentsDropdown().is(GlDropdown)).toBe(true); + expect(findEnvironmentsDropdown().is(GlDeprecatedDropdown)).toBe(true); expect(findSimpleFilters().exists()).toBe(true); expect(findLogControlButtons().exists()).toBe(true); @@ -167,7 +167,7 @@ describe('EnvironmentLogs', () => { it('displays a disabled environments dropdown', () => { expect(findEnvironmentsDropdown().attributes('disabled')).toBe('true'); - expect(findEnvironmentsDropdown().findAll(GlDropdownItem).length).toBe(0); + expect(findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem).length).toBe(0); }); it('does not update buttons state', () => { @@ -244,7 +244,7 @@ describe('EnvironmentLogs', () => { }); it('populates environments dropdown', () => { - const items = findEnvironmentsDropdown().findAll(GlDropdownItem); + const items = findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem); expect(findEnvironmentsDropdown().props('text')).toBe(mockEnvName); expect(items.length).toBe(mockEnvironments.length); mockEnvironments.forEach((env, i) => { @@ -254,7 +254,7 @@ describe('EnvironmentLogs', () => { }); it('dropdown has one environment selected', () => { - const items = findEnvironmentsDropdown().findAll(GlDropdownItem); + const items = findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem); mockEnvironments.forEach((env, i) => { const item = items.at(i); @@ -289,7 +289,7 @@ describe('EnvironmentLogs', () => { describe('when user clicks', () => { it('environment name, trace is refreshed', () => { - const items = findEnvironmentsDropdown().findAll(GlDropdownItem); + const items = findEnvironmentsDropdown().findAll(GlDeprecatedDropdownItem); const index = 1; // any env expect(dispatch).not.toHaveBeenCalledWith(`${module}/showEnvironment`, expect.anything()); diff --git a/spec/frontend/logs/components/log_advanced_filters_spec.js b/spec/frontend/logs/components/log_advanced_filters_spec.js index adcd6b4fb07..007c5000e16 100644 --- a/spec/frontend/logs/components/log_advanced_filters_spec.js +++ b/spec/frontend/logs/components/log_advanced_filters_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import { defaultTimeRange } from '~/vue_shared/constants'; import { GlFilteredSearch } from '@gitlab/ui'; +import { defaultTimeRange } from '~/vue_shared/constants'; import { convertToFixedRange } from '~/lib/utils/datetime_range'; import { createStore } from '~/logs/stores'; import { TOKEN_TYPE_POD_NAME } from '~/logs/constants'; diff --git a/spec/frontend/logs/components/log_simple_filters_spec.js b/spec/frontend/logs/components/log_simple_filters_spec.js index 13504a2b1fc..e739621431e 100644 --- a/spec/frontend/logs/components/log_simple_filters_spec.js +++ b/spec/frontend/logs/components/log_simple_filters_spec.js @@ -1,4 +1,4 @@ -import { GlIcon, GlDropdownItem } from '@gitlab/ui'; +import { GlIcon, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { createStore } from '~/logs/stores'; import { mockPods, mockPodName } from '../mock_data'; @@ -17,7 +17,7 @@ describe('LogSimpleFilters', () => { const findPodsNoPodsText = () => wrapper.find({ ref: 'noPodsMsg' }); const findPodsDropdownItems = () => findPodsDropdown() - .findAll(GlDropdownItem) + .findAll(GlDeprecatedDropdownItem) .filter(item => !item.is('[disabled]')); const mockPodsLoading = () => { diff --git a/spec/frontend/logs/mock_data.js b/spec/frontend/logs/mock_data.js index f9b3508e01c..f4c567a2ea3 100644 --- a/spec/frontend/logs/mock_data.js +++ b/spec/frontend/logs/mock_data.js @@ -36,6 +36,16 @@ export const mockManagedApps = [ path: '/root/autodevops-deploy/-/clusters/15', gitlab_managed_apps_logs_path: '/root/autodevops-deploy/-/logs?cluster_id=15', }, + { + cluster_type: 'project_type', + enabled: true, + environment_scope: '*', + name: 'kubernetes-cluster-2', + provider_type: 'user', + status: 'connected', + path: '/root/autodevops-deploy/-/clusters/16', + gitlab_managed_apps_logs_path: null, + }, ]; export const mockPodName = 'production-764c58d697-aaaaa'; diff --git a/spec/frontend/logs/stores/actions_spec.js b/spec/frontend/logs/stores/actions_spec.js index acd9536a682..e4501abdc76 100644 --- a/spec/frontend/logs/stores/actions_spec.js +++ b/spec/frontend/logs/stores/actions_spec.js @@ -17,7 +17,7 @@ import { import { defaultTimeRange } from '~/vue_shared/constants'; import axios from '~/lib/utils/axios_utils'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; import { mockPodName, diff --git a/spec/frontend/logs/stores/mutations_spec.js b/spec/frontend/logs/stores/mutations_spec.js index 137533f02d7..4a095e0f26e 100644 --- a/spec/frontend/logs/stores/mutations_spec.js +++ b/spec/frontend/logs/stores/mutations_spec.js @@ -272,7 +272,8 @@ describe('Logs Store Mutations', () => { mutations[types.RECEIVE_MANAGED_APPS_DATA_SUCCESS](state, mockManagedApps); - expect(state.managedApps.options).toEqual(mockManagedApps); + expect(state.managedApps.options.length).toEqual(1); + expect(state.managedApps.options).toEqual([mockManagedApps[0]]); expect(state.managedApps.isLoading).toBe(false); }); }); diff --git a/spec/frontend/maintenance_mode_settings/components/app_spec.js b/spec/frontend/maintenance_mode_settings/components/app_spec.js index 0453354b008..ad753642e85 100644 --- a/spec/frontend/maintenance_mode_settings/components/app_spec.js +++ b/spec/frontend/maintenance_mode_settings/components/app_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlToggle, GlFormTextarea, GlButton } from '@gitlab/ui'; import MaintenanceModeSettingsApp from '~/maintenance_mode_settings/components/app.vue'; -import { GlToggle, GlFormTextarea, GlDeprecatedButton } from '@gitlab/ui'; describe('MaintenanceModeSettingsApp', () => { let wrapper; @@ -16,7 +16,7 @@ describe('MaintenanceModeSettingsApp', () => { const findMaintenanceModeSettingsContainer = () => wrapper.find('article'); const findGlToggle = () => wrapper.find(GlToggle); const findGlFormTextarea = () => wrapper.find(GlFormTextarea); - const findGlButton = () => wrapper.find(GlDeprecatedButton); + const findGlButton = () => wrapper.find(GlButton); describe('template', () => { beforeEach(() => { @@ -35,7 +35,7 @@ describe('MaintenanceModeSettingsApp', () => { expect(findGlFormTextarea().exists()).toBe(true); }); - it('renders the GlDeprecatedButton', () => { + it('renders the GlButton', () => { expect(findGlButton().exists()).toBe(true); }); }); diff --git a/spec/frontend/merge_request_spec.js b/spec/frontend/merge_request_spec.js index f4f2a78f5f7..16f04d032fd 100644 --- a/spec/frontend/merge_request_spec.js +++ b/spec/frontend/merge_request_spec.js @@ -1,10 +1,10 @@ import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; import MergeRequest from '~/merge_request'; import CloseReopenReportToggle from '~/close_reopen_report_toggle'; import IssuablesHelper from '~/helpers/issuables_helper'; -import { TEST_HOST } from 'spec/test_constants'; describe('MergeRequest', () => { const test = {}; diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index ad373d04ec0..85a4ee8974e 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -1,11 +1,11 @@ import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; +import initMrPage from 'helpers/init_vue_mr_page_helper'; import axios from '~/lib/utils/axios_utils'; import MergeRequestTabs from '~/merge_request_tabs'; import '~/commit/pipelines/pipelines_bundle'; import '~/lib/utils/common_utils'; import 'vendor/jquery.scrollTo'; -import initMrPage from 'helpers/init_vue_mr_page_helper'; jest.mock('~/lib/utils/webpack', () => ({ resetServiceWorkersPublicPath: jest.fn(), diff --git a/spec/frontend/milestones/project_milestone_combobox_spec.js b/spec/frontend/milestones/project_milestone_combobox_spec.js index a7321d21559..2265c9bdc2e 100644 --- a/spec/frontend/milestones/project_milestone_combobox_spec.js +++ b/spec/frontend/milestones/project_milestone_combobox_spec.js @@ -1,9 +1,9 @@ -import { milestones as projectMilestones } from './mock_data'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; import { GlNewDropdown, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import MilestoneCombobox from '~/milestones/project_milestone_combobox.vue'; +import { milestones as projectMilestones } from './mock_data'; const TEST_SEARCH_ENDPOINT = '/api/v4/projects/8/search'; diff --git a/spec/frontend/monitoring/alert_widget_spec.js b/spec/frontend/monitoring/alert_widget_spec.js index f0355dfa01b..193dbb3e63f 100644 --- a/spec/frontend/monitoring/alert_widget_spec.js +++ b/spec/frontend/monitoring/alert_widget_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlTooltip, GlSprintf, GlBadge } from '@gitlab/ui'; -import AlertWidget from '~/monitoring/components/alert_widget.vue'; import waitForPromises from 'helpers/wait_for_promises'; -import createFlash from '~/flash'; +import AlertWidget from '~/monitoring/components/alert_widget.vue'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; const mockReadAlert = jest.fn(); const mockCreateAlert = jest.fn(); 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 e7c51d82cd2..7ef956f8e05 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -20,7 +20,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" - modalid="duplicateDashboard" toggle-class="dropdown-menu-toggle" /> </div> @@ -33,26 +32,24 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="mb-2 pr-2 d-flex d-sm-block" > - <gl-dropdown-stub + <gl-new-dropdown-stub + category="tertiary" class="flex-grow-1" data-qa-selector="environments_dropdown" + headertext="" id="monitor-environments-dropdown" menu-class="monitor-environment-dropdown-menu" + size="medium" text="production" - toggle-class="dropdown-menu-toggle" + toggleclass="dropdown-menu-toggle" + variant="default" > <div class="d-flex flex-column overflow-hidden" > - <gl-dropdown-header-stub - class="monitor-environment-dropdown-header text-center" - > - - Environment - - </gl-dropdown-header-stub> - - <gl-dropdown-divider-stub /> + <gl-new-dropdown-header-stub> + Environment + </gl-new-dropdown-header-stub> <gl-search-box-by-type-stub class="m-2" @@ -72,7 +69,7 @@ exports[`Dashboard template matches the default snapshot 1`] = ` </div> </div> - </gl-dropdown-stub> + </gl-new-dropdown-stub> </div> <div @@ -100,45 +97,23 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="d-sm-flex" > - <div - class="mb-2 mr-2 d-flex" - > - <div - class="flex-grow-1" - title="Star dashboard" - > - <gl-deprecated-button-stub - class="w-100" - size="md" - variant="default" - > - <gl-icon-stub - name="star-o" - size="16" - /> - </gl-deprecated-button-stub> - </div> - </div> - <!----> <!----> - <!----> - - <!----> - - <!----> - - <!----> + <div + class="gl-mb-3 gl-mr-3 d-flex d-sm-block" + > + <actions-menu-stub + custommetricspath="/monitoring/monitor-project/prometheus/metrics" + defaultbranch="master" + isootbdashboard="true" + validatequerypath="/monitoring/monitor-project/prometheus/metrics/validate_query" + /> + </div> <!----> </div> - - <duplicate-dashboard-modal-stub - defaultbranch="master" - modalid="duplicateDashboard" - /> </div> <empty-state-stub diff --git a/spec/frontend/monitoring/components/alert_widget_form_spec.js b/spec/frontend/monitoring/components/alert_widget_form_spec.js index a8416216a94..6d71a9b09e5 100644 --- a/spec/frontend/monitoring/components/alert_widget_form_spec.js +++ b/spec/frontend/monitoring/components/alert_widget_form_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; +import INVALID_URL from '~/lib/utils/invalid_url'; import AlertWidgetForm from '~/monitoring/components/alert_widget_form.vue'; import ModalStub from '../stubs/modal_stub'; @@ -24,7 +25,13 @@ describe('AlertWidgetForm', () => { const propsWithAlertData = { ...defaultProps, alertsToManage: { - alert: { alert_path: alertPath, operator: '<', threshold: 5, metricId }, + alert: { + alert_path: alertPath, + operator: '<', + threshold: 5, + metricId, + runbookUrl: INVALID_URL, + }, }, configuredAlert: metricId, }; @@ -46,15 +53,11 @@ describe('AlertWidgetForm', () => { const modal = () => wrapper.find(ModalStub); const modalTitle = () => modal().attributes('title'); const submitButton = () => modal().find(GlLink); + const findRunbookField = () => modal().find('[data-testid="alertRunbookField"]'); + const findThresholdField = () => modal().find('[data-qa-selector="alert_threshold_field"]'); const submitButtonTrackingOpts = () => JSON.parse(submitButton().attributes('data-tracking-options')); - const e = { - preventDefault: jest.fn(), - }; - - beforeEach(() => { - e.preventDefault.mockReset(); - }); + const stubEvent = { preventDefault: jest.fn() }; afterEach(() => { if (wrapper) wrapper.destroy(); @@ -81,35 +84,34 @@ describe('AlertWidgetForm', () => { expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.create); }); - it('emits a "create" event when form submitted without existing alert', () => { - createComponent(); + it('emits a "create" event when form submitted without existing alert', async () => { + createComponent(defaultProps); - wrapper.vm.selectQuery('9'); - wrapper.setData({ - threshold: 900, - }); + modal().vm.$emit('shown'); + + findThresholdField().vm.$emit('input', 900); + findRunbookField().vm.$emit('input', INVALID_URL); - wrapper.vm.handleSubmit(e); + modal().vm.$emit('ok', stubEvent); expect(wrapper.emitted().create[0]).toEqual([ { alert: undefined, operator: '>', threshold: 900, - prometheus_metric_id: '9', + prometheus_metric_id: '8', + runbookUrl: INVALID_URL, }, ]); - expect(e.preventDefault).toHaveBeenCalledTimes(1); }); it('resets form when modal is dismissed (hidden)', () => { - createComponent(); + createComponent(defaultProps); - wrapper.vm.selectQuery('9'); - wrapper.vm.selectQuery('>'); - wrapper.setData({ - threshold: 800, - }); + modal().vm.$emit('shown'); + + findThresholdField().vm.$emit('input', 800); + findRunbookField().vm.$emit('input', INVALID_URL); modal().vm.$emit('hidden'); @@ -117,6 +119,7 @@ describe('AlertWidgetForm', () => { expect(wrapper.vm.operator).toBe(null); expect(wrapper.vm.threshold).toBe(null); expect(wrapper.vm.prometheusMetricId).toBe(null); + expect(wrapper.vm.runbookUrl).toBe(null); }); it('sets selectedAlert to the provided configuredAlert on modal show', () => { @@ -163,7 +166,7 @@ describe('AlertWidgetForm', () => { beforeEach(() => { createComponent(propsWithAlertData); - wrapper.vm.selectQuery(metricId); + modal().vm.$emit('shown'); }); it('sets tracking options for delete alert', () => { @@ -176,7 +179,7 @@ describe('AlertWidgetForm', () => { }); it('emits "delete" event when form values unchanged', () => { - wrapper.vm.handleSubmit(e); + modal().vm.$emit('ok', stubEvent); expect(wrapper.emitted().delete[0]).toEqual([ { @@ -184,37 +187,52 @@ describe('AlertWidgetForm', () => { operator: '<', threshold: 5, prometheus_metric_id: '8', + runbookUrl: INVALID_URL, }, ]); - expect(e.preventDefault).toHaveBeenCalledTimes(1); }); + }); - it('emits "update" event when form changed', () => { - wrapper.setData({ - threshold: 11, - }); + it('emits "update" event when form changed', () => { + const updatedRunbookUrl = `${INVALID_URL}/test`; - wrapper.vm.handleSubmit(e); + createComponent(propsWithAlertData); - expect(wrapper.emitted().update[0]).toEqual([ - { - alert: 'alert', - operator: '<', - threshold: 11, - prometheus_metric_id: '8', - }, - ]); - expect(e.preventDefault).toHaveBeenCalledTimes(1); - }); + modal().vm.$emit('shown'); + + findRunbookField().vm.$emit('input', updatedRunbookUrl); + findThresholdField().vm.$emit('input', 11); - it('sets tracking options for update alert', () => { - wrapper.setData({ + modal().vm.$emit('ok', stubEvent); + + expect(wrapper.emitted().update[0]).toEqual([ + { + alert: 'alert', + operator: '<', threshold: 11, - }); + prometheus_metric_id: '8', + runbookUrl: updatedRunbookUrl, + }, + ]); + }); + + it('sets tracking options for update alert', async () => { + createComponent(propsWithAlertData); + + modal().vm.$emit('shown'); + + findThresholdField().vm.$emit('input', 11); + + await wrapper.vm.$nextTick(); + + expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); + }); + + describe('alert runbooks', () => { + it('shows the runbook field', () => { + createComponent(); - return wrapper.vm.$nextTick(() => { - expect(submitButtonTrackingOpts()).toEqual(dataTrackingOptions.update); - }); + expect(findRunbookField().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/gauge_spec.js b/spec/frontend/monitoring/components/charts/gauge_spec.js new file mode 100644 index 00000000000..850e2ca87db --- /dev/null +++ b/spec/frontend/monitoring/components/charts/gauge_spec.js @@ -0,0 +1,215 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlGaugeChart } from '@gitlab/ui/dist/charts'; +import GaugeChart from '~/monitoring/components/charts/gauge.vue'; +import { gaugeChartGraphData } from '../../graph_data'; + +describe('Gauge Chart component', () => { + const defaultGraphData = gaugeChartGraphData(); + + let wrapper; + + const findGaugeChart = () => wrapper.find(GlGaugeChart); + + const createWrapper = ({ ...graphProps } = {}) => { + wrapper = shallowMount(GaugeChart, { + propsData: { + graphData: { + ...defaultGraphData, + ...graphProps, + }, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('chart component', () => { + it('is rendered when props are passed', () => { + createWrapper(); + + expect(findGaugeChart().exists()).toBe(true); + }); + }); + + describe('min and max', () => { + const MIN_DEFAULT = 0; + const MAX_DEFAULT = 100; + + it('are passed to chart component', () => { + createWrapper(); + + expect(findGaugeChart().props('min')).toBe(100); + expect(findGaugeChart().props('max')).toBe(1000); + }); + + const invalidCases = [undefined, NaN, 'a string']; + + it.each(invalidCases)( + 'if min has invalid value, defaults are used for both min and max', + invalidValue => { + createWrapper({ minValue: invalidValue }); + + expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); + expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); + }, + ); + + it.each(invalidCases)( + 'if max has invalid value, defaults are used for both min and max', + invalidValue => { + createWrapper({ minValue: invalidValue }); + + expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); + expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); + }, + ); + + it('if min is bigger than max, defaults are used for both min and max', () => { + createWrapper({ minValue: 100, maxValue: 0 }); + + expect(findGaugeChart().props('min')).toBe(MIN_DEFAULT); + expect(findGaugeChart().props('max')).toBe(MAX_DEFAULT); + }); + }); + + describe('thresholds', () => { + it('thresholds are set on chart', () => { + createWrapper(); + + expect(findGaugeChart().props('thresholds')).toEqual([500, 800]); + }); + + it('when no thresholds are defined, a default threshold is defined at 95% of max_value', () => { + createWrapper({ + minValue: 0, + maxValue: 100, + thresholds: {}, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([95]); + }); + + it('when out of min-max bounds thresholds are defined, a default threshold is defined at 95% of the range between min_value and max_value', () => { + createWrapper({ + thresholds: { + values: [-10, 1500], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + + describe('when mode is absolute', () => { + it('only valid threshold values are used', () => { + createWrapper({ + thresholds: { + mode: 'absolute', + values: [undefined, 10, 110, NaN, 'a string', 400], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([110, 400]); + }); + + it('if all threshold values are invalid, a default threshold is defined at 95% of the range between min_value and max_value', () => { + createWrapper({ + thresholds: { + mode: 'absolute', + values: [NaN, undefined, 'a string', 1500], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + }); + + describe('when mode is percentage', () => { + it('when values outside of 0-100 bounds are used, a default threshold is defined at 95% of max_value', () => { + createWrapper({ + thresholds: { + mode: 'percentage', + values: [110], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + + it('if all threshold values are invalid, a default threshold is defined at 95% of max_value', () => { + createWrapper({ + thresholds: { + mode: 'percentage', + values: [NaN, undefined, 'a string', 1500], + }, + }); + + expect(findGaugeChart().props('thresholds')).toEqual([855]); + }); + }); + }); + + describe('split (the number of ticks on the chart arc)', () => { + const SPLIT_DEFAULT = 10; + + it('is passed to chart as prop', () => { + createWrapper(); + + expect(findGaugeChart().props('splitNumber')).toBe(20); + }); + + it('if not explicitly set, passes a default value to chart', () => { + createWrapper({ split: '' }); + + expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); + }); + + it('if set as a number that is not an integer, passes the default value to chart', () => { + createWrapper({ split: 10.5 }); + + expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); + }); + + it('if set as a negative number, passes the default value to chart', () => { + createWrapper({ split: -10 }); + + expect(findGaugeChart().props('splitNumber')).toBe(SPLIT_DEFAULT); + }); + }); + + describe('text (the text displayed on the gauge for the current value)', () => { + it('displays the query result value when format is not set', () => { + createWrapper({ format: '' }); + + expect(findGaugeChart().props('text')).toBe('3'); + }); + + it('displays the query result value when format is set to invalid value', () => { + createWrapper({ format: 'invalid' }); + + expect(findGaugeChart().props('text')).toBe('3'); + }); + + it('displays a formatted query result value when format is set', () => { + createWrapper(); + + expect(findGaugeChart().props('text')).toBe('3kB'); + }); + + it('displays a placeholder value when metric is empty', () => { + createWrapper({ metrics: [] }); + + expect(findGaugeChart().props('text')).toBe('--'); + }); + }); + + describe('value', () => { + it('correct value is passed', () => { + createWrapper(); + + expect(findGaugeChart().props('value')).toBe(3); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/charts/heatmap_spec.js b/spec/frontend/monitoring/components/charts/heatmap_spec.js index 2a1c78025ae..27a2021e9be 100644 --- a/spec/frontend/monitoring/components/charts/heatmap_spec.js +++ b/spec/frontend/monitoring/components/charts/heatmap_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlHeatmap } from '@gitlab/ui/dist/charts'; import timezoneMock from 'timezone-mock'; import Heatmap from '~/monitoring/components/charts/heatmap.vue'; -import { graphDataPrometheusQueryRangeMultiTrack } from '../../mock_data'; +import { heatmapGraphData } from '../../graph_data'; describe('Heatmap component', () => { let wrapper; @@ -10,10 +10,12 @@ describe('Heatmap component', () => { const findChart = () => wrapper.find(GlHeatmap); + const graphData = heatmapGraphData(); + const createWrapper = (props = {}) => { wrapper = shallowMount(Heatmap, { propsData: { - graphData: graphDataPrometheusQueryRangeMultiTrack, + graphData: heatmapGraphData(), containerWidth: 100, ...props, }, @@ -38,11 +40,11 @@ describe('Heatmap component', () => { }); it('should display a label on the x axis', () => { - expect(wrapper.vm.xAxisName).toBe(graphDataPrometheusQueryRangeMultiTrack.x_label); + expect(wrapper.vm.xAxisName).toBe(graphData.xLabel); }); it('should display a label on the y axis', () => { - expect(wrapper.vm.yAxisName).toBe(graphDataPrometheusQueryRangeMultiTrack.y_label); + expect(wrapper.vm.yAxisName).toBe(graphData.y_label); }); // According to the echarts docs https://echarts.apache.org/en/option.html#series-heatmap.data @@ -54,24 +56,24 @@ describe('Heatmap component', () => { const row = wrapper.vm.chartData[0]; expect(row.length).toBe(3); - expect(wrapper.vm.chartData.length).toBe(30); + expect(wrapper.vm.chartData.length).toBe(6); }); it('returns a series of labels for the x axis', () => { const { xAxisLabels } = wrapper.vm; - expect(xAxisLabels.length).toBe(5); + expect(xAxisLabels.length).toBe(2); }); describe('y axis labels', () => { - const gmtLabels = ['3:00 PM', '4:00 PM', '5:00 PM', '6:00 PM', '7:00 PM', '8:00 PM']; + const gmtLabels = ['8:10 PM', '8:12 PM', '8:14 PM']; it('y-axis labels are formatted in AM/PM format', () => { expect(findChart().props('yAxisLabels')).toEqual(gmtLabels); }); describe('when in PT timezone', () => { - const ptLabels = ['8:00 AM', '9:00 AM', '10:00 AM', '11:00 AM', '12:00 PM', '1:00 PM']; + const ptLabels = ['1:10 PM', '1:12 PM', '1:14 PM']; const utcLabels = gmtLabels; // Identical in this case beforeAll(() => { diff --git a/spec/frontend/monitoring/components/charts/options_spec.js b/spec/frontend/monitoring/components/charts/options_spec.js index 1c8fdc01e3e..3372d27e4f9 100644 --- a/spec/frontend/monitoring/components/charts/options_spec.js +++ b/spec/frontend/monitoring/components/charts/options_spec.js @@ -1,5 +1,9 @@ import { SUPPORTED_FORMATS } from '~/lib/utils/unit_format'; -import { getYAxisOptions, getTooltipFormatter } from '~/monitoring/components/charts/options'; +import { + getYAxisOptions, + getTooltipFormatter, + getValidThresholds, +} from '~/monitoring/components/charts/options'; describe('options spec', () => { describe('getYAxisOptions', () => { @@ -82,4 +86,242 @@ describe('options spec', () => { expect(formatter(1)).toBe('1.000B'); }); }); + + describe('getValidThresholds', () => { + const invalidCases = [null, undefined, NaN, 'a string', true, false]; + + let thresholds; + + afterEach(() => { + thresholds = null; + }); + + it('returns same thresholds when passed values within range', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([10, 50]); + }); + + it('filters out thresholds that are out of range', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [-5, 10, 110], + }); + + expect(thresholds).toEqual([10]); + }); + it('filters out duplicate thresholds', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [5, 5, 10, 10], + }); + + expect(thresholds).toEqual([5, 10]); + }); + + it('sorts passed thresholds and applies only the first two in ascending order', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, 1, 35, 20, 5], + }); + + expect(thresholds).toEqual([1, 5]); + }); + + it('thresholds equal to min or max are filtered out', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [0, 100], + }); + + expect(thresholds).toEqual([]); + }); + + it.each(invalidCases)('invalid values for thresholds are filtered out', invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, invalidValue], + }); + + expect(thresholds).toEqual([10]); + }); + + describe('range', () => { + it('when range is not defined, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it('when min is not defined, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { max: 100 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it('when max is not defined, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it('when min is larger than max, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 100, max: 0 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }); + + it.each(invalidCases)( + 'when min has invalid value, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: invalidValue, max: 100 }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }, + ); + + it.each(invalidCases)( + 'when max has invalid value, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: invalidValue }, + values: [10, 20], + }); + + expect(thresholds).toEqual([]); + }, + ); + }); + + describe('values', () => { + it('if values parameter is omitted, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + }); + + expect(thresholds).toEqual([]); + }); + + it('if there are no values passed, empty result is returned', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [], + }); + + expect(thresholds).toEqual([]); + }); + + it.each(invalidCases)( + 'if invalid values are passed, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [invalidValue], + }); + + expect(thresholds).toEqual([]); + }, + ); + }); + + describe('mode', () => { + it.each(invalidCases)( + 'if invalid values are passed, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: invalidValue, + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([]); + }, + ); + + it('if mode is not passed, empty result is returned', () => { + thresholds = getValidThresholds({ + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([]); + }); + + describe('absolute mode', () => { + it('absolute mode behaves correctly', () => { + thresholds = getValidThresholds({ + mode: 'absolute', + range: { min: 0, max: 100 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([10, 50]); + }); + }); + + describe('percentage mode', () => { + it('percentage mode behaves correctly', () => { + thresholds = getValidThresholds({ + mode: 'percentage', + range: { min: 0, max: 1000 }, + values: [10, 50], + }); + + expect(thresholds).toEqual([100, 500]); + }); + + const outOfPercentBoundsValues = [-1, 0, 100, 101]; + it.each(outOfPercentBoundsValues)( + 'when values out of 0-100 range are passed, empty result is returned', + invalidValue => { + thresholds = getValidThresholds({ + mode: 'percentage', + range: { min: 0, max: 1000 }, + values: [invalidValue], + }); + + expect(thresholds).toEqual([]); + }, + ); + }); + }); + + it('calling without passing object parameter returns empty array', () => { + thresholds = getValidThresholds(); + + expect(thresholds).toEqual([]); + }); + }); }); diff --git a/spec/frontend/monitoring/components/charts/single_stat_spec.js b/spec/frontend/monitoring/components/charts/single_stat_spec.js index 3783b1eebd2..37712eb3012 100644 --- a/spec/frontend/monitoring/components/charts/single_stat_spec.js +++ b/spec/frontend/monitoring/components/charts/single_stat_spec.js @@ -1,71 +1,91 @@ import { shallowMount } from '@vue/test-utils'; +import { GlSingleStat } from '@gitlab/ui/dist/charts'; import SingleStatChart from '~/monitoring/components/charts/single_stat.vue'; import { singleStatGraphData } from '../../graph_data'; describe('Single Stat Chart component', () => { - let singleStatChart; + let wrapper; - beforeEach(() => { - singleStatChart = shallowMount(SingleStatChart, { + const createComponent = (props = {}) => { + wrapper = shallowMount(SingleStatChart, { propsData: { graphData: singleStatGraphData({}, { unit: 'MB' }), + ...props, }, }); + }; + + const findChart = () => wrapper.find(GlSingleStat); + + beforeEach(() => { + createComponent(); }); afterEach(() => { - singleStatChart.destroy(); + wrapper.destroy(); }); describe('computed', () => { describe('statValue', () => { it('should interpolate the value and unit props', () => { - expect(singleStatChart.vm.statValue).toBe('1.00MB'); + expect(findChart().props('value')).toBe('1.00MB'); }); it('should change the value representation to a percentile one', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ max_value: 120 }, { value: 91 }), }); - expect(singleStatChart.vm.statValue).toContain('75.83%'); + expect(findChart().props('value')).toContain('75.83%'); }); it('should display NaN for non numeric maxValue values', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ max_value: 'not a number' }), }); - expect(singleStatChart.vm.statValue).toContain('NaN'); + expect(findChart().props('value')).toContain('NaN'); }); it('should display NaN for missing query values', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ max_value: 120 }, { value: 'NaN' }), }); - expect(singleStatChart.vm.statValue).toContain('NaN'); + expect(findChart().props('value')).toContain('NaN'); + }); + + it('should not display `unit` when `unit` is undefined', () => { + createComponent({ + graphData: singleStatGraphData({}, { unit: undefined }), + }); + + expect(findChart().props('value')).not.toContain('undefined'); }); - describe('field attribute', () => { + it('should not display `unit` when `unit` is null', () => { + createComponent({ + graphData: singleStatGraphData({}, { unit: null }), + }); + + expect(findChart().props('value')).not.toContain('null'); + }); + + describe('when a field attribute is set', () => { it('displays a label value instead of metric value when field attribute is used', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ field: 'job' }, { isVector: true }), }); - return singleStatChart.vm.$nextTick(() => { - expect(singleStatChart.vm.statValue).toContain('prometheus'); - }); + expect(findChart().props('value')).toContain('prometheus'); }); it('displays No data to display if field attribute is not present', () => { - singleStatChart.setProps({ + createComponent({ graphData: singleStatGraphData({ field: 'this-does-not-exist' }), }); - return singleStatChart.vm.$nextTick(() => { - expect(singleStatChart.vm.statValue).toContain('No data to display'); - }); + expect(findChart().props('value')).toContain('No data to display'); }); }); }); diff --git a/spec/frontend/monitoring/components/charts/time_series_spec.js b/spec/frontend/monitoring/components/charts/time_series_spec.js index 97386be9e32..6f9a89feb3e 100644 --- a/spec/frontend/monitoring/components/charts/time_series_spec.js +++ b/spec/frontend/monitoring/components/charts/time_series_spec.js @@ -12,7 +12,12 @@ import { import { shallowWrapperContainsSlotText } from 'helpers/vue_test_utils_helper'; import { panelTypes, chartHeight } from '~/monitoring/constants'; import TimeSeries from '~/monitoring/components/charts/time_series.vue'; -import { deploymentData, mockProjectDir, annotationsData } from '../../mock_data'; +import { + deploymentData, + mockProjectDir, + annotationsData, + mockFixedTimeRange, +} from '../../mock_data'; import { timeSeriesGraphData } from '../../graph_data'; @@ -42,6 +47,7 @@ describe('Time series component', () => { deploymentData, annotations: annotationsData, projectPath: `${TEST_HOST}${mockProjectDir}`, + timeRange: mockFixedTimeRange, ...props, }, stubs: { @@ -382,6 +388,25 @@ describe('Time series component', () => { }); describe('chartOptions', () => { + describe('x-Axis bounds', () => { + it('is set to the time range bounds', () => { + expect(getChartOptions().xAxis).toMatchObject({ + min: mockFixedTimeRange.start, + max: mockFixedTimeRange.end, + }); + }); + + it('is not set if time range is not set or incorrectly set', () => { + wrapper.setProps({ + timeRange: {}, + }); + return wrapper.vm.$nextTick(() => { + expect(getChartOptions().xAxis).not.toHaveProperty('min'); + expect(getChartOptions().xAxis).not.toHaveProperty('max'); + }); + }); + }); + describe('dataZoom', () => { it('renders with scroll handle icons', () => { expect(getChartOptions().dataZoom).toHaveLength(1); diff --git a/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js new file mode 100644 index 00000000000..024b2cbd7f1 --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_actions_menu_spec.js @@ -0,0 +1,440 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlNewDropdownItem } from '@gitlab/ui'; +import { createStore } from '~/monitoring/stores'; +import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; +import { setupAllDashboards, setupStoreWithData } from '../store_utils'; +import { redirectTo } from '~/lib/utils/url_utility'; +import Tracking from '~/tracking'; +import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue'; +import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; +import { dashboardActionsMenuProps, dashboardGitResponse } from '../mock_data'; +import * as types from '~/monitoring/stores/mutation_types'; + +jest.mock('~/lib/utils/url_utility', () => ({ + redirectTo: jest.fn(), + queryToObject: jest.fn(), +})); + +describe('Actions menu', () => { + const ootbDashboards = [dashboardGitResponse[0], dashboardGitResponse[2]]; + const customDashboard = dashboardGitResponse[1]; + + let store; + let wrapper; + + const findAddMetricItem = () => wrapper.find('[data-testid="add-metric-item"]'); + const findAddPanelItemEnabled = () => wrapper.find('[data-testid="add-panel-item-enabled"]'); + const findAddPanelItemDisabled = () => wrapper.find('[data-testid="add-panel-item-disabled"]'); + const findAddMetricModal = () => wrapper.find('[data-testid="add-metric-modal"]'); + const findAddMetricModalSubmitButton = () => + wrapper.find('[data-testid="add-metric-modal-submit-button"]'); + const findStarDashboardItem = () => wrapper.find('[data-testid="star-dashboard-item"]'); + const findEditDashboardItemEnabled = () => + wrapper.find('[data-testid="edit-dashboard-item-enabled"]'); + const findEditDashboardItemDisabled = () => + wrapper.find('[data-testid="edit-dashboard-item-disabled"]'); + const findDuplicateDashboardItem = () => wrapper.find('[data-testid="duplicate-dashboard-item"]'); + const findDuplicateDashboardModal = () => + wrapper.find('[data-testid="duplicate-dashboard-modal"]'); + const findCreateDashboardItem = () => wrapper.find('[data-testid="create-dashboard-item"]'); + const findCreateDashboardModal = () => wrapper.find('[data-testid="create-dashboard-modal"]'); + + const createShallowWrapper = (props = {}, options = {}) => { + wrapper = shallowMount(ActionsMenu, { + propsData: { ...dashboardActionsMenuProps, ...props }, + store, + ...options, + }); + }; + + beforeEach(() => { + store = createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('add metric item', () => { + it('is rendered when custom metrics are available', () => { + createShallowWrapper(); + + return wrapper.vm.$nextTick(() => { + expect(findAddMetricItem().exists()).toBe(true); + }); + }); + + it('is not rendered when custom metrics are not available', () => { + createShallowWrapper({ + addingMetricsAvailable: false, + }); + + return wrapper.vm.$nextTick(() => { + expect(findAddMetricItem().exists()).toBe(false); + }); + }); + + describe('when available', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + it('modal for custom metrics form is rendered', () => { + expect(findAddMetricModal().exists()).toBe(true); + expect(findAddMetricModal().attributes().modalid).toBe('addMetric'); + }); + + it('add metric modal submit button exists', () => { + expect(findAddMetricModalSubmitButton().exists()).toBe(true); + }); + + it('renders custom metrics form fields', () => { + expect(wrapper.find(CustomMetricsFormFields).exists()).toBe(true); + }); + }); + + describe('when not available', () => { + beforeEach(() => { + createShallowWrapper({ addingMetricsAvailable: false }); + }); + + it('modal for custom metrics form is not rendered', () => { + expect(findAddMetricModal().exists()).toBe(false); + }); + }); + + describe('adding new metric from modal', () => { + let origPage; + + beforeEach(done => { + jest.spyOn(Tracking, 'event').mockReturnValue(); + createShallowWrapper(); + + setupStoreWithData(store); + + origPage = document.body.dataset.page; + document.body.dataset.page = 'projects:environments:metrics'; + + wrapper.vm.$nextTick(done); + }); + + afterEach(() => { + document.body.dataset.page = origPage; + }); + + it('is tracked', done => { + const submitButton = findAddMetricModalSubmitButton().vm; + + wrapper.vm.$nextTick(() => { + submitButton.$el.click(); + wrapper.vm.$nextTick(() => { + expect(Tracking.event).toHaveBeenCalledWith( + document.body.dataset.page, + 'click_button', + { + label: 'add_new_metric', + property: 'modal', + value: undefined, + }, + ); + done(); + }); + }); + }); + }); + }); + + describe('add panel item', () => { + const GlNewDropdownItemStub = { + extends: GlNewDropdownItem, + props: { + to: [String, Object], + }, + }; + + let $route; + + beforeEach(() => { + $route = { name: DASHBOARD_PAGE, params: { dashboard: 'my_dashboard.yml' } }; + + createShallowWrapper( + { + isOotbDashboard: false, + }, + { + mocks: { $route }, + stubs: { GlNewDropdownItem: GlNewDropdownItemStub }, + }, + ); + }); + + it('is disabled for ootb dashboards', () => { + createShallowWrapper({ + isOotbDashboard: true, + }); + + return wrapper.vm.$nextTick(() => { + expect(findAddPanelItemDisabled().exists()).toBe(true); + }); + }); + + it('is visible for custom dashboards', () => { + expect(findAddPanelItemEnabled().exists()).toBe(true); + }); + + it('renders a link to the new panel page for custom dashboards', () => { + expect(findAddPanelItemEnabled().props('to')).toEqual({ + name: PANEL_NEW_PAGE, + params: { + dashboard: 'my_dashboard.yml', + }, + }); + }); + }); + + describe('edit dashboard yml item', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + describe('when current dashboard is custom', () => { + beforeEach(() => { + setupAllDashboards(store, customDashboard.path); + }); + + it('enabled item is rendered and has falsy disabled attribute', () => { + expect(findEditDashboardItemEnabled().exists()).toBe(true); + expect(findEditDashboardItemEnabled().attributes('disabled')).toBe(undefined); + }); + + it('enabled item links to their edit path', () => { + expect(findEditDashboardItemEnabled().attributes('href')).toBe( + customDashboard.project_blob_path, + ); + }); + + it('disabled item is not rendered', () => { + expect(findEditDashboardItemDisabled().exists()).toBe(false); + }); + }); + + describe.each(ootbDashboards)('when current dashboard is OOTB', dashboard => { + beforeEach(() => { + setupAllDashboards(store, dashboard.path); + }); + + it('disabled item is rendered and has disabled attribute set on it', () => { + expect(findEditDashboardItemDisabled().exists()).toBe(true); + expect(findEditDashboardItemDisabled().attributes('disabled')).toBe(''); + }); + + it('enabled item is not rendered', () => { + expect(findEditDashboardItemEnabled().exists()).toBe(false); + }); + }); + }); + + describe('duplicate dashboard item', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + describe.each(ootbDashboards)('when current dashboard is OOTB', dashboard => { + beforeEach(() => { + setupAllDashboards(store, dashboard.path); + }); + + it('is rendered', () => { + expect(findDuplicateDashboardItem().exists()).toBe(true); + }); + + it('duplicate dashboard modal is rendered', () => { + expect(findDuplicateDashboardModal().exists()).toBe(true); + }); + + it('clicking on item opens up the duplicate dashboard modal', () => { + const modalId = 'duplicateDashboard'; + const modalTrigger = findDuplicateDashboardItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + }); + + describe('when current dashboard is custom', () => { + beforeEach(() => { + setupAllDashboards(store, customDashboard.path); + }); + + it('is not rendered', () => { + expect(findDuplicateDashboardItem().exists()).toBe(false); + }); + + it('duplicate dashboard modal is not rendered', () => { + expect(findDuplicateDashboardModal().exists()).toBe(false); + }); + }); + + describe('when no dashboard is set', () => { + it('is not rendered', () => { + expect(findDuplicateDashboardItem().exists()).toBe(false); + }); + + it('duplicate dashboard modal is not rendered', () => { + expect(findDuplicateDashboardModal().exists()).toBe(false); + }); + }); + + describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = 'root/sandbox'; + + setupAllDashboards(store, dashboardGitResponse[0].path); + }); + + it('redirects to the newly created dashboard', () => { + delete window.location; + window.location = new URL('https://localhost'); + + const newDashboard = dashboardGitResponse[1]; + + const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; + findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard); + + return wrapper.vm.$nextTick().then(() => { + expect(redirectTo).toHaveBeenCalled(); + expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); + }); + }); + }); + }); + + describe('star dashboard item', () => { + beforeEach(() => { + createShallowWrapper(); + setupAllDashboards(store); + + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + it('is shown', () => { + expect(findStarDashboardItem().exists()).toBe(true); + }); + + it('is not disabled', () => { + expect(findStarDashboardItem().attributes('disabled')).toBeFalsy(); + }); + + it('is disabled when starring is taking place', () => { + store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); + + return wrapper.vm.$nextTick(() => { + expect(findStarDashboardItem().exists()).toBe(true); + expect(findStarDashboardItem().attributes('disabled')).toBe('true'); + }); + }); + + it('on click it dispatches a toggle star action', () => { + findStarDashboardItem().vm.$emit('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/toggleStarredValue', + undefined, + ); + }); + }); + + describe('when dashboard is not starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[0].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('item text shows "Star dashboard"', () => { + expect(findStarDashboardItem().html()).toMatch(/Star dashboard/); + }); + }); + + describe('when dashboard is starred', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + currentDashboard: dashboardGitResponse[1].path, + }); + return wrapper.vm.$nextTick(); + }); + + it('item text shows "Unstar dashboard"', () => { + expect(findStarDashboardItem().html()).toMatch(/Unstar dashboard/); + }); + }); + }); + + describe('create dashboard item', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + it('is rendered by default but it is disabled', () => { + expect(findCreateDashboardItem().attributes('disabled')).toBe('true'); + }); + + describe('when project path is set', () => { + const mockProjectPath = 'root/sandbox'; + const mockAddDashboardDocPath = '/doc/add-dashboard'; + + beforeEach(() => { + store.state.monitoringDashboard.projectPath = mockProjectPath; + store.state.monitoringDashboard.addDashboardDocumentationPath = mockAddDashboardDocPath; + }); + + it('is not disabled', () => { + expect(findCreateDashboardItem().attributes('disabled')).toBe(undefined); + }); + + it('renders a modal for creating a dashboard', () => { + expect(findCreateDashboardModal().exists()).toBe(true); + }); + + it('clicking opens up the modal', () => { + const modalId = 'createDashboard'; + const modalTrigger = findCreateDashboardItem(); + const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + + modalTrigger.trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + }); + }); + + it('modal gets passed correct props', () => { + expect(findCreateDashboardModal().props('projectPath')).toBe(mockProjectPath); + expect(findCreateDashboardModal().props('addDashboardDocumentationPath')).toBe( + mockAddDashboardDocPath, + ); + }); + }); + + describe('when project path is not set', () => { + beforeEach(() => { + store.state.monitoringDashboard.projectPath = null; + }); + + it('is disabled', () => { + expect(findCreateDashboardItem().attributes('disabled')).toBe('true'); + }); + + it('does not render a modal for creating a dashboard', () => { + expect(findCreateDashboardModal().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_header_spec.js b/spec/frontend/monitoring/components/dashboard_header_spec.js index 5a1a615c703..5cf24706ebd 100644 --- a/spec/frontend/monitoring/components/dashboard_header_spec.js +++ b/spec/frontend/monitoring/components/dashboard_header_spec.js @@ -1,16 +1,23 @@ import { shallowMount } from '@vue/test-utils'; +import { GlNewDropdownItem, 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'; +import RefreshButton from '~/monitoring/components/refresh_button.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; -import DuplicateDashboardModal from '~/monitoring/components/duplicate_dashboard_modal.vue'; -import CreateDashboardModal from '~/monitoring/components/create_dashboard_modal.vue'; -import { setupAllDashboards } from '../store_utils'; +import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; +import ActionsMenu from '~/monitoring/components/dashboard_actions_menu.vue'; +import { setupAllDashboards, setupStoreWithDashboard, setupStoreWithData } from '../store_utils'; import { + environmentData, dashboardGitResponse, selfMonitoringDashboardGitResponse, dashboardHeaderProps, } from '../mock_data'; import { redirectTo } from '~/lib/utils/url_utility'; +const mockProjectPath = 'https://path/to/project'; + jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), queryToObject: jest.fn(), @@ -21,13 +28,22 @@ describe('Dashboard header', () => { let store; let wrapper; - const findActionsMenu = () => wrapper.find('[data-testid="actions-menu"]'); - const findCreateDashboardMenuItem = () => - findActionsMenu().find('[data-testid="action-create-dashboard"]'); - const findCreateDashboardDuplicateItem = () => - findActionsMenu().find('[data-testid="action-duplicate-dashboard"]'); - const findDuplicateDashboardModal = () => wrapper.find(DuplicateDashboardModal); - const findCreateDashboardModal = () => wrapper.find('[data-testid="create-dashboard-modal"]'); + const findDashboardDropdown = () => wrapper.find(DashboardsDropdown); + + const findEnvsDropdown = () => wrapper.find({ ref: 'monitorEnvironmentsDropdown' }); + const findEnvsDropdownItems = () => findEnvsDropdown().findAll(GlNewDropdownItem); + const findEnvsDropdownSearch = () => findEnvsDropdown().find(GlSearchBoxByType); + const findEnvsDropdownSearchMsg = () => wrapper.find({ ref: 'monitorEnvironmentsDropdownMsg' }); + const findEnvsDropdownLoadingIcon = () => findEnvsDropdown().find(GlLoadingIcon); + + const findDateTimePicker = () => wrapper.find(DateTimePicker); + const findRefreshButton = () => wrapper.find(RefreshButton); + + const findActionsMenu = () => wrapper.find(ActionsMenu); + + const setSearchTerm = searchTerm => { + store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); + }; const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(DashboardHeader, { @@ -45,139 +61,315 @@ describe('Dashboard header', () => { wrapper.destroy(); }); - describe('when a dashboard has been duplicated in the duplicate dashboard modal', () => { + describe('dashboards dropdown', () => { beforeEach(() => { - store.state.monitoringDashboard.projectPath = 'root/sandbox'; + store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { + projectPath: mockProjectPath, + }); + + createShallowWrapper(); }); - /** - * The duplicate dashboard modal gets called both by a menu item from the - * dashboards dropdown and by an item from the actions menu. - * - * This spec is context agnostic, so it addresses all cases where the - * duplicate dashboard modal gets called. - */ - it('redirects to the newly created dashboard', () => { - delete window.location; - window.location = new URL('https://localhost'); - const newDashboard = dashboardGitResponse[1]; + it('shows the dashboard dropdown', () => { + expect(findDashboardDropdown().exists()).toBe(true); + }); - createShallowWrapper(); + it('when an out of the box dashboard is selected, encodes dashboard path', () => { + findDashboardDropdown().vm.$emit('selectDashboard', { + path: '.gitlab/dashboards/dashboard©.yml', + out_of_the_box_dashboard: true, + display_name: 'A display name', + }); - const newDashboardUrl = 'root/sandbox/-/metrics/dashboard.yml'; - findDuplicateDashboardModal().vm.$emit('dashboardDuplicated', newDashboard); + expect(redirectTo).toHaveBeenCalledWith( + `${mockProjectPath}/-/metrics/.gitlab%2Fdashboards%2Fdashboard%26copy.yml`, + ); + }); - return wrapper.vm.$nextTick().then(() => { - expect(redirectTo).toHaveBeenCalled(); - expect(redirectTo).toHaveBeenCalledWith(newDashboardUrl); + it('when a custom dashboard is selected, encodes dashboard display name', () => { + findDashboardDropdown().vm.$emit('selectDashboard', { + path: '.gitlab/dashboards/file&path.yml', + display_name: 'dashboard©.yml', }); + + expect(redirectTo).toHaveBeenCalledWith(`${mockProjectPath}/-/metrics/dashboard%26copy.yml`); }); }); - describe('actions menu', () => { + describe('environments dropdown', () => { beforeEach(() => { - store.state.monitoringDashboard.projectPath = ''; createShallowWrapper(); }); - it('is rendered if projectPath is set in store', () => { - store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + it('shows the environments dropdown', () => { + expect(findEnvsDropdown().exists()).toBe(true); + }); - return wrapper.vm.$nextTick().then(() => { - expect(findActionsMenu().exists()).toBe(true); + it('renders a search input', () => { + expect(findEnvsDropdownSearch().exists()).toBe(true); + }); + + describe('when environments data is not loaded', () => { + beforeEach(() => { + setupStoreWithDashboard(store); + return wrapper.vm.$nextTick(); + }); + + it('there are no environments listed', () => { + expect(findEnvsDropdownItems()).toHaveLength(0); + }); + }); + + describe('when environments data is loaded', () => { + const currentDashboard = dashboardGitResponse[0].path; + const currentEnvironmentName = environmentData[0].name; + + beforeEach(() => { + setupStoreWithData(store); + store.state.monitoringDashboard.projectPath = mockProjectPath; + store.state.monitoringDashboard.currentDashboard = currentDashboard; + store.state.monitoringDashboard.currentEnvironmentName = currentEnvironmentName; + + return wrapper.vm.$nextTick(); + }); + + it('renders dropdown items with the environment name', () => { + const path = `${mockProjectPath}/-/metrics/${encodeURIComponent(currentDashboard)}`; + + findEnvsDropdownItems().wrappers.forEach((itemWrapper, index) => { + const { name, id } = environmentData[index]; + const idParam = encodeURIComponent(id); + + expect(itemWrapper.text()).toBe(name); + expect(itemWrapper.attributes('href')).toBe(`${path}?environment=${idParam}`); + }); + }); + + it('environments dropdown items can be checked', () => { + const items = findEnvsDropdownItems(); + const checkItems = findEnvsDropdownItems().filter(item => item.props('isCheckItem')); + + expect(items).toHaveLength(checkItems.length); + }); + + it('checks the currently selected environment', () => { + const selectedItems = findEnvsDropdownItems().filter(item => item.props('isChecked')); + + expect(selectedItems).toHaveLength(1); + expect(selectedItems.at(0).text()).toBe(currentEnvironmentName); + }); + + it('filters rendered dropdown items', () => { + const searchTerm = 'production'; + const resultEnvs = environmentData.filter(({ name }) => name.indexOf(searchTerm) !== -1); + setSearchTerm(searchTerm); + + return wrapper.vm.$nextTick().then(() => { + expect(findEnvsDropdownItems()).toHaveLength(resultEnvs.length); + }); + }); + + it('does not filter dropdown items if search term is empty string', () => { + const searchTerm = ''; + setSearchTerm(searchTerm); + + return wrapper.vm.$nextTick(() => { + expect(findEnvsDropdownItems()).toHaveLength(environmentData.length); + }); + }); + + it("shows error message if search term doesn't match", () => { + const searchTerm = 'does-not-exist'; + setSearchTerm(searchTerm); + + return wrapper.vm.$nextTick(() => { + expect(findEnvsDropdownSearchMsg().isVisible()).toBe(true); + }); + }); + + it('shows loading element when environments fetch is still loading', () => { + store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); + + return wrapper.vm + .$nextTick() + .then(() => { + expect(findEnvsDropdownLoadingIcon().exists()).toBe(true); + }) + .then(() => { + store.commit( + `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, + environmentData, + ); + }) + .then(() => { + expect(findEnvsDropdownLoadingIcon().exists()).toBe(false); + }); }); }); + }); - it('is not rendered if projectPath is not set in store', () => { - expect(findActionsMenu().exists()).toBe(false); + describe('date time picker', () => { + beforeEach(() => { + createShallowWrapper(); }); - it('contains a modal', () => { - store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + it('is rendered', () => { + expect(findDateTimePicker().exists()).toBe(true); + }); - return wrapper.vm.$nextTick().then(() => { - expect(findActionsMenu().contains(CreateDashboardModal)).toBe(true); + describe('timezone setting', () => { + const setupWithTimezone = value => { + store = createStore({ dashboardTimezone: value }); + createShallowWrapper(); + }; + + describe('local timezone is enabled by default', () => { + it('shows the data time picker in local timezone', () => { + expect(findDateTimePicker().props('utc')).toBe(false); + }); + }); + + describe('when LOCAL timezone is enabled', () => { + beforeEach(() => { + setupWithTimezone('LOCAL'); + }); + + it('shows the data time picker in local timezone', () => { + expect(findDateTimePicker().props('utc')).toBe(false); + }); + }); + + describe('when UTC timezone is enabled', () => { + beforeEach(() => { + setupWithTimezone('UTC'); + }); + + it('shows the data time picker in UTC format', () => { + expect(findDateTimePicker().props('utc')).toBe(true); + }); }); }); + }); + + describe('refresh button', () => { + beforeEach(() => { + createShallowWrapper(); + }); + + it('is rendered', () => { + expect(findRefreshButton().exists()).toBe(true); + }); + }); + + describe('external dashboard link', () => { + beforeEach(() => { + store.state.monitoringDashboard.externalDashboardUrl = '/mockUrl'; + createShallowWrapper(); + + return wrapper.vm.$nextTick(); + }); + + it('shows the link', () => { + const externalDashboardButton = wrapper.find('.js-external-dashboard-link'); + + expect(externalDashboardButton.exists()).toBe(true); + expect(externalDashboardButton.is(GlButton)).toBe(true); + expect(externalDashboardButton.text()).toContain('View full dashboard'); + }); + }); - const duplicableCases = [ - null, // When no path is specified, it uses the default dashboard path. + describe('actions menu', () => { + const ootbDashboards = [ dashboardGitResponse[0].path, - dashboardGitResponse[2].path, selfMonitoringDashboardGitResponse[0].path, ]; + const customDashboards = [ + dashboardGitResponse[1].path, + selfMonitoringDashboardGitResponse[1].path, + ]; - describe.each(duplicableCases)( - 'when the selected dashboard can be duplicated', - dashboardPath => { - it('contains a "Create New" menu item and a "Duplicate Dashboard" menu item', () => { - store.state.monitoringDashboard.projectPath = 'https://path/to/project'; - setupAllDashboards(store, dashboardPath); + it('is rendered', () => { + createShallowWrapper(); - return wrapper.vm.$nextTick().then(() => { - expect(findCreateDashboardMenuItem().exists()).toBe(true); - expect(findCreateDashboardDuplicateItem().exists()).toBe(true); - }); + expect(findActionsMenu().exists()).toBe(true); + }); + + describe('adding metrics prop', () => { + it.each(ootbDashboards)('gets passed true if current dashboard is OOTB', dashboardPath => { + createShallowWrapper({ customMetricsAvailable: true }); + + store.state.monitoringDashboard.emptyState = false; + setupAllDashboards(store, dashboardPath); + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(true); }); - }, - ); + }); - const nonDuplicableCases = [ - dashboardGitResponse[1].path, - selfMonitoringDashboardGitResponse[1].path, - ]; + it.each(customDashboards)( + 'gets passed false if current dashboard is custom', + dashboardPath => { + createShallowWrapper({ customMetricsAvailable: true }); - describe.each(nonDuplicableCases)( - 'when the selected dashboard cannot be duplicated', - dashboardPath => { - it('contains a "Create New" menu item and no "Duplicate Dashboard" menu item', () => { - store.state.monitoringDashboard.projectPath = 'https://path/to/project'; + store.state.monitoringDashboard.emptyState = false; setupAllDashboards(store, dashboardPath); return wrapper.vm.$nextTick().then(() => { - expect(findCreateDashboardMenuItem().exists()).toBe(true); - expect(findCreateDashboardDuplicateItem().exists()).toBe(false); + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); }); + }, + ); + + it('gets passed false if empty state is shown', () => { + createShallowWrapper({ customMetricsAvailable: true }); + + store.state.monitoringDashboard.emptyState = true; + setupAllDashboards(store, ootbDashboards[0]); + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); }); - }, - ); - }); + }); - describe('actions menu modals', () => { - const url = 'https://path/to/project'; + it('gets passed false if custom metrics are not available', () => { + createShallowWrapper({ customMetricsAvailable: false }); - beforeEach(() => { - store.state.monitoringDashboard.projectPath = url; - setupAllDashboards(store); + store.state.monitoringDashboard.emptyState = false; + setupAllDashboards(store, ootbDashboards[0]); - createShallowWrapper(); + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('addingMetricsAvailable')).toBe(false); + }); + }); }); - it('Clicking on "Create New" opens up a modal', () => { - const modalId = 'createDashboard'; - const modalTrigger = findCreateDashboardMenuItem(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + it('custom metrics path gets passed', () => { + const path = 'https://path/to/customMetrics'; - modalTrigger.trigger('click'); + createShallowWrapper({ customMetricsPath: path }); return wrapper.vm.$nextTick().then(() => { - expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + expect(findActionsMenu().props('customMetricsPath')).toBe(path); }); }); - it('"Create new dashboard" modal contains correct buttons', () => { - expect(findCreateDashboardModal().props('projectPath')).toBe(url); + it('validate query path gets passed', () => { + const path = 'https://path/to/validateQuery'; + + createShallowWrapper({ validateQueryPath: path }); + + return wrapper.vm.$nextTick().then(() => { + expect(findActionsMenu().props('validateQueryPath')).toBe(path); + }); }); - it('"Duplicate Dashboard" opens up a modal', () => { - const modalId = 'duplicateDashboard'; - const modalTrigger = findCreateDashboardDuplicateItem(); - const rootEmit = jest.spyOn(wrapper.vm.$root, '$emit'); + it('default branch gets passed', () => { + const branch = 'branchName'; - modalTrigger.trigger('click'); + createShallowWrapper({ defaultBranch: branch }); return wrapper.vm.$nextTick().then(() => { - expect(rootEmit.mock.calls[0]).toContainEqual(modalId); + expect(findActionsMenu().props('defaultBranch')).toBe(branch); }); }); }); diff --git a/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js new file mode 100644 index 00000000000..587ddd23d3f --- /dev/null +++ b/spec/frontend/monitoring/components/dashboard_panel_builder_spec.js @@ -0,0 +1,234 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlCard, GlForm, GlFormTextarea, GlAlert } from '@gitlab/ui'; +import { createStore } from '~/monitoring/stores'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; +import * as types from '~/monitoring/stores/mutation_types'; +import { metricsDashboardResponse } from '../fixture_data'; +import { mockTimeRange } from '../mock_data'; + +import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue'; +import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; + +const mockPanel = metricsDashboardResponse.dashboard.panel_groups[0].panels[0]; + +describe('dashboard invalid url parameters', () => { + let store; + let wrapper; + let mockShowToast; + + const createComponent = (props = {}, options = {}) => { + wrapper = shallowMount(DashboardPanelBuilder, { + propsData: { ...props }, + store, + stubs: { + GlCard, + }, + mocks: { + $toast: { + show: mockShowToast, + }, + }, + options, + }); + }; + + const findForm = () => wrapper.find(GlForm); + const findTxtArea = () => findForm().find(GlFormTextarea); + const findSubmitBtn = () => findForm().find('[type="submit"]'); + const findClipboardCopyBtn = () => wrapper.find({ ref: 'clipboardCopyBtn' }); + const findViewDocumentationBtn = () => wrapper.find({ ref: 'viewDocumentationBtn' }); + const findOpenRepositoryBtn = () => wrapper.find({ ref: 'openRepositoryBtn' }); + const findPanel = () => wrapper.find(DashboardPanel); + const findTimeRangePicker = () => wrapper.find(DateTimePicker); + const findRefreshButton = () => wrapper.find('[data-testid="previewRefreshButton"]'); + + beforeEach(() => { + mockShowToast = jest.fn(); + store = createStore(); + createComponent(); + jest.spyOn(store, 'dispatch').mockResolvedValue(); + }); + + afterEach(() => {}); + + it('is mounted', () => { + expect(wrapper.exists()).toBe(true); + }); + + it('displays an empty dashboard panel', () => { + expect(findPanel().exists()).toBe(true); + expect(findPanel().props('graphData')).toBe(null); + }); + + it('does not fetch initial data by default', () => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + + describe('yml form', () => { + it('form exists and can be submitted', () => { + expect(findForm().exists()).toBe(true); + expect(findSubmitBtn().exists()).toBe(true); + expect(findSubmitBtn().is('[disabled]')).toBe(false); + }); + + it('form has a text area with a default value', () => { + expect(findTxtArea().exists()).toBe(true); + + const value = findTxtArea().attributes('value'); + + // Panel definition should contain a title and a type + expect(value).toContain('title:'); + expect(value).toContain('type:'); + }); + + it('"copy to clipboard" button works', () => { + findClipboardCopyBtn().vm.$emit('click'); + const clipboardText = findClipboardCopyBtn().attributes('data-clipboard-text'); + + expect(clipboardText).toContain('title:'); + expect(clipboardText).toContain('type:'); + + expect(mockShowToast).toHaveBeenCalledTimes(1); + }); + + it('on submit fetches a panel preview', () => { + findForm().vm.$emit('submit', new Event('submit')); + + return wrapper.vm.$nextTick().then(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/fetchPanelPreview', + expect.stringContaining('title:'), + ); + }); + }); + + describe('when form is submitted', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.REQUEST_PANEL_PREVIEW}`, 'mock yml content'); + return wrapper.vm.$nextTick(); + }); + + it('submit button is disabled', () => { + expect(findSubmitBtn().is('[disabled]')).toBe(true); + }); + }); + }); + + describe('time range picker', () => { + it('is visible by default', () => { + expect(findTimeRangePicker().exists()).toBe(true); + }); + + it('when changed does not trigger data fetch unless preview panel button is clicked', () => { + // mimic initial state where SET_PANEL_PREVIEW_IS_SHOWN is set to false + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, false); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + it('when changed triggers data fetch if preview panel button is clicked', () => { + findForm().vm.$emit('submit', new Event('submit')); + + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_TIME_RANGE}`, mockTimeRange); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).toHaveBeenCalled(); + }); + }); + }); + + describe('refresh', () => { + it('is visible by default', () => { + expect(findRefreshButton().exists()).toBe(true); + }); + + it('when clicked does not trigger data fetch unless preview panel button is clicked', () => { + // mimic initial state where SET_PANEL_PREVIEW_IS_SHOWN is set to false + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, false); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + + it('when clicked triggers data fetch if preview panel button is clicked', () => { + // mimic state where preview is visible. SET_PANEL_PREVIEW_IS_SHOWN is set to true + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_IS_SHOWN}`, true); + + findRefreshButton().vm.$emit('click'); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/fetchPanelPreviewMetrics', + undefined, + ); + }); + }); + }); + + describe('instructions card', () => { + const mockDocsPath = '/docs-path'; + const mockProjectPath = '/project-path'; + + beforeEach(() => { + store.state.monitoringDashboard.addDashboardDocumentationPath = mockDocsPath; + store.state.monitoringDashboard.projectPath = mockProjectPath; + + createComponent(); + }); + + it('displays next actions for the user', () => { + expect(findViewDocumentationBtn().exists()).toBe(true); + expect(findViewDocumentationBtn().attributes('href')).toBe(mockDocsPath); + + expect(findOpenRepositoryBtn().exists()).toBe(true); + expect(findOpenRepositoryBtn().attributes('href')).toBe(mockProjectPath); + }); + }); + + describe('when there is an error', () => { + const mockError = 'an error ocurred!'; + + beforeEach(() => { + store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_FAILURE}`, mockError); + return wrapper.vm.$nextTick(); + }); + + it('displays an alert', () => { + expect(wrapper.find(GlAlert).exists()).toBe(true); + expect(wrapper.find(GlAlert).text()).toBe(mockError); + }); + + it('displays an empty dashboard panel', () => { + expect(findPanel().props('graphData')).toBe(null); + }); + + it('changing time range should not refetch data', () => { + store.commit(`monitoringDashboard/${types.SET_PANEL_PREVIEW_TIME_RANGE}`, mockTimeRange); + + return wrapper.vm.$nextTick(() => { + expect(store.dispatch).not.toHaveBeenCalled(); + }); + }); + }); + + describe('when panel data is available', () => { + beforeEach(() => { + store.commit(`monitoringDashboard/${types.RECEIVE_PANEL_PREVIEW_SUCCESS}`, mockPanel); + return wrapper.vm.$nextTick(); + }); + + it('displays no alert', () => { + expect(wrapper.find(GlAlert).exists()).toBe(false); + }); + + it('displays panel with data', () => { + const { title, type } = wrapper.find(DashboardPanel).props('graphData'); + + expect(title).toBe(mockPanel.title); + expect(type).toBe(mockPanel.type); + }); + }); +}); diff --git a/spec/frontend/monitoring/components/dashboard_panel_spec.js b/spec/frontend/monitoring/components/dashboard_panel_spec.js index 693818aa55a..fb96bcc042f 100644 --- a/spec/frontend/monitoring/components/dashboard_panel_spec.js +++ b/spec/frontend/monitoring/components/dashboard_panel_spec.js @@ -2,23 +2,23 @@ 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 invalidUrl from '~/lib/utils/invalid_url'; import axios from '~/lib/utils/axios_utils'; -import { GlNewDropdownItem as GlDropdownItem } from '@gitlab/ui'; import AlertWidget from '~/monitoring/components/alert_widget.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { + mockAlert, mockLogsHref, mockLogsPath, mockNamespace, mockNamespacedData, mockTimeRange, - graphDataPrometheusQueryRangeMultiTrack, barMockData, } from '../mock_data'; import { dashboardProps, graphData, graphDataEmpty } from '../fixture_data'; -import { anomalyGraphData, singleStatGraphData } from '../graph_data'; +import { anomalyGraphData, singleStatGraphData, heatmapGraphData } from '../graph_data'; import { panelTypes } from '~/monitoring/constants'; @@ -56,9 +56,10 @@ describe('Dashboard Panel', () => { const findCtxMenu = () => wrapper.find({ ref: 'contextualMenu' }); const findMenuItems = () => wrapper.findAll(GlDropdownItem); const findMenuItemByText = text => findMenuItems().filter(i => i.text() === text); + const findAlertsWidget = () => wrapper.find(AlertWidget); - const createWrapper = (props, options) => { - wrapper = shallowMount(DashboardPanel, { + const createWrapper = (props, { mountFn = shallowMount, ...options } = {}) => { + wrapper = mountFn(DashboardPanel, { propsData: { graphData, settingsPath: dashboardProps.settingsPath, @@ -79,6 +80,9 @@ describe('Dashboard Panel', () => { }); }; + const setMetricsSavedToDb = val => + monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); + beforeEach(() => { setTestTimeout(1000); @@ -235,7 +239,7 @@ describe('Dashboard Panel', () => { ${anomalyGraphData()} | ${MonitorAnomalyChart} | ${false} ${dataWithType(panelTypes.COLUMN)} | ${MonitorColumnChart} | ${false} ${dataWithType(panelTypes.STACKED_COLUMN)} | ${MonitorStackedColumnChart} | ${false} - ${graphDataPrometheusQueryRangeMultiTrack} | ${MonitorHeatmapChart} | ${false} + ${heatmapGraphData()} | ${MonitorHeatmapChart} | ${false} ${barMockData} | ${MonitorBarChart} | ${false} `('when $data.type data is provided', ({ data, component, hasCtxMenu }) => { const attrs = { attr1: 'attr1Value', attr2: 'attr2Value' }; @@ -255,6 +259,35 @@ describe('Dashboard Panel', () => { }); }); }); + + describe('computed', () => { + describe('fixedCurrentTimeRange', () => { + it('returns fixed time for valid time range', () => { + state.timeRange = mockTimeRange; + return wrapper.vm.$nextTick(() => { + expect(findTimeChart().props('timeRange')).toEqual( + expect.objectContaining({ + start: expect.any(String), + end: expect.any(String), + }), + ); + }); + }); + + it.each` + input | output + ${''} | ${{}} + ${undefined} | ${{}} + ${null} | ${{}} + ${'2020-12-03'} | ${{}} + `('returns $output for invalid input like $input', ({ input, output }) => { + state.timeRange = input; + return wrapper.vm.$nextTick(() => { + expect(findTimeChart().props('timeRange')).toEqual(output); + }); + }); + }); + }); }); describe('Edit custom metric dropdown item', () => { @@ -444,7 +477,7 @@ describe('Dashboard Panel', () => { describe('csvText', () => { it('converts metrics data from json to csv', () => { - const header = `timestamp,${graphData.y_label}`; + const header = `timestamp,"${graphData.y_label} > ${graphData.metrics[0].label}"`; const data = graphData.metrics[0].result[0].values; const firstRow = `${data[0][0]},${data[0][1]}`; const secondRow = `${data[1][0]},${data[1][1]}`; @@ -523,7 +556,7 @@ describe('Dashboard Panel', () => { }); it('displays a heatmap in local timezone', () => { - createWrapper({ graphData: graphDataPrometheusQueryRangeMultiTrack }); + createWrapper({ graphData: heatmapGraphData() }); expect(wrapper.find(MonitorHeatmapChart).props('timezone')).toBe('LOCAL'); }); @@ -538,7 +571,7 @@ describe('Dashboard Panel', () => { }); it('displays a heatmap with UTC', () => { - createWrapper({ graphData: graphDataPrometheusQueryRangeMultiTrack }); + createWrapper({ graphData: heatmapGraphData() }); expect(wrapper.find(MonitorHeatmapChart).props('timezone')).toBe('UTC'); }); }); @@ -573,10 +606,6 @@ describe('Dashboard Panel', () => { }); describe('panel alerts', () => { - const setMetricsSavedToDb = val => - monitoringDashboard.getters.metricsSavedToDb.mockReturnValue(val); - const findAlertsWidget = () => wrapper.find(AlertWidget); - beforeEach(() => { mockGetterReturnValue('metricsSavedToDb', []); @@ -702,4 +731,60 @@ describe('Dashboard Panel', () => { expect(findManageLinksItem().exists()).toBe(false); }); }); + + describe('Runbook url', () => { + const findRunbookLinks = () => wrapper.findAll('[data-testid="runbookLink"]'); + const { metricId } = graphData.metrics[0]; + const { alert_path: alertPath } = mockAlert; + + const mockRunbookAlert = { + ...mockAlert, + metricId, + }; + + beforeEach(() => { + mockGetterReturnValue('metricsSavedToDb', []); + }); + + it('does not show a runbook link when alerts are not present', () => { + createWrapper(); + + expect(findRunbookLinks().length).toBe(0); + }); + + describe('when alerts are present', () => { + beforeEach(() => { + setMetricsSavedToDb([metricId]); + + createWrapper({ + alertsEndpoint: '/endpoint', + prometheusAlertsAvailable: true, + }); + }); + + it('does not show a runbook link when a runbook is not set', async () => { + findAlertsWidget().vm.$emit('setAlerts', alertPath, { + ...mockRunbookAlert, + runbookUrl: '', + }); + + await wrapper.vm.$nextTick(); + + expect(findRunbookLinks().length).toBe(0); + }); + + it('shows a runbook link when a runbook is set', async () => { + findAlertsWidget().vm.$emit('setAlerts', alertPath, mockRunbookAlert); + + await wrapper.vm.$nextTick(); + + expect(findRunbookLinks().length).toBe(1); + expect( + findRunbookLinks() + .at(0) + .attributes('href'), + ).toBe(invalidUrl); + }); + }); + }); }); diff --git a/spec/frontend/monitoring/components/dashboard_spec.js b/spec/frontend/monitoring/components/dashboard_spec.js index 4b7f7a9ddb3..f37d95317ab 100644 --- a/spec/frontend/monitoring/components/dashboard_spec.js +++ b/spec/frontend/monitoring/components/dashboard_spec.js @@ -1,19 +1,14 @@ import { shallowMount, mount } from '@vue/test-utils'; -import Tracking from '~/tracking'; -import { ESC_KEY, ESC_KEY_IE11 } from '~/lib/utils/keys'; -import { GlModal, GlDropdownItem, GlDeprecatedButton, GlIcon } from '@gitlab/ui'; -import { objectToQuery } from '~/lib/utils/url_utility'; import VueDraggable from 'vuedraggable'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'helpers/test_constants'; +import { ESC_KEY } from '~/lib/utils/keys'; +import { objectToQuery } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; import Dashboard from '~/monitoring/components/dashboard.vue'; import DashboardHeader from '~/monitoring/components/dashboard_header.vue'; -import RefreshButton from '~/monitoring/components/refresh_button.vue'; -import DateTimePicker from '~/vue_shared/components/date_time_picker/date_time_picker.vue'; -import CustomMetricsFormFields from '~/custom_metrics/components/custom_metrics_form_fields.vue'; -import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; import EmptyState from '~/monitoring/components/empty_state.vue'; import GroupEmptyState from '~/monitoring/components/group_empty_state.vue'; import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; @@ -29,14 +24,13 @@ import { setupStoreWithDataForPanelCount, setupStoreWithLinks, } from '../store_utils'; -import { environmentData, dashboardGitResponse, storeVariables } from '../mock_data'; +import { dashboardGitResponse, storeVariables } from '../mock_data'; import { metricsDashboardViewModel, metricsDashboardPanelCount, dashboardProps, } from '../fixture_data'; -import createFlash from '~/flash'; -import { TEST_HOST } from 'helpers/test_constants'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); @@ -45,14 +39,6 @@ describe('Dashboard', () => { let wrapper; let mock; - const findDashboardHeader = () => wrapper.find(DashboardHeader); - const findEnvironmentsDropdown = () => - findDashboardHeader().find({ ref: 'monitorEnvironmentsDropdown' }); - const findAllEnvironmentsDropdownItems = () => findEnvironmentsDropdown().findAll(GlDropdownItem); - const setSearchTerm = searchTerm => { - store.commit(`monitoringDashboard/${types.SET_ENVIRONMENTS_FILTER}`, searchTerm); - }; - const createShallowWrapper = (props = {}, options = {}) => { wrapper = shallowMount(Dashboard, { propsData: { ...dashboardProps, ...props }, @@ -90,28 +76,6 @@ describe('Dashboard', () => { } }); - describe('no metrics are available yet', () => { - beforeEach(() => { - createShallowWrapper(); - }); - - it('shows the environment selector', () => { - expect(findEnvironmentsDropdown().exists()).toBe(true); - }); - }); - - describe('no data found', () => { - beforeEach(() => { - createShallowWrapper(); - - return wrapper.vm.$nextTick(); - }); - - it('shows the environment selector dropdown', () => { - expect(findEnvironmentsDropdown().exists()).toBe(true); - }); - }); - describe('request information to the server', () => { it('calls to set time range and fetch data', () => { createShallowWrapper({ hasMetrics: true }); @@ -149,17 +113,14 @@ describe('Dashboard', () => { }); it('fetches the metrics data with proper time window', () => { - jest.spyOn(store, 'dispatch'); - createMountedWrapper({ hasMetrics: true }); - store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - return wrapper.vm.$nextTick().then(() => { - expect(store.dispatch).toHaveBeenCalled(); + expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/fetchData', undefined); + expect(store.dispatch).toHaveBeenCalledWith( + 'monitoringDashboard/setTimeRange', + expect.objectContaining({ duration: { seconds: 28800 } }), + ); }); }); }); @@ -427,37 +388,6 @@ describe('Dashboard', () => { ); }); }); - - describe('when custom dashboard is selected', () => { - const windowLocation = window.location; - const findDashboardDropdown = () => wrapper.find(DashboardHeader).find(DashboardsDropdown); - - beforeEach(() => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - projectPath: TEST_HOST, - }); - - delete window.location; - window.location = { ...windowLocation, assign: jest.fn() }; - createMountedWrapper(); - - return wrapper.vm.$nextTick(); - }); - - afterEach(() => { - window.location = windowLocation; - }); - - it('encodes dashboard param', () => { - findDashboardDropdown().vm.$emit('selectDashboard', { - path: '.gitlab/dashboards/dashboard©.yml', - display_name: 'dashboard©.yml', - }); - expect(window.location.assign).toHaveBeenCalledWith( - `${TEST_HOST}/-/metrics/dashboard%26copy.yml`, - ); - }); - }); }); describe('when all panels in the first group are loading', () => { @@ -500,21 +430,6 @@ describe('Dashboard', () => { return wrapper.vm.$nextTick(); }); - it('renders the environments dropdown with a number of environments', () => { - expect(findAllEnvironmentsDropdownItems().length).toEqual(environmentData.length); - - findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => { - const anchorEl = itemWrapper.find('a'); - if (anchorEl.exists()) { - const href = anchorEl.attributes('href'); - const currentDashboard = encodeURIComponent(dashboardGitResponse[0].path); - const environmentId = encodeURIComponent(environmentData[index].id); - const url = `${TEST_HOST}/-/metrics/${currentDashboard}?environment=${environmentId}`; - expect(href).toBe(url); - } - }); - }); - it('it does not show loading icons in any group', () => { setupStoreWithData(store); @@ -524,127 +439,6 @@ describe('Dashboard', () => { }); }); }); - - // Note: This test is not working, .active does not show the active environment - // eslint-disable-next-line jest/no-disabled-tests - it.skip('renders the environments dropdown with a single active element', () => { - const activeItem = findAllEnvironmentsDropdownItems().wrappers.filter(itemWrapper => - itemWrapper.find('.active').exists(), - ); - - expect(activeItem.length).toBe(1); - }); - }); - - describe('star dashboards', () => { - const findToggleStar = () => wrapper.find(DashboardHeader).find({ ref: 'toggleStarBtn' }); - const findToggleStarIcon = () => findToggleStar().find(GlIcon); - - beforeEach(() => { - createShallowWrapper(); - setupAllDashboards(store); - }); - - it('toggle star button is shown', () => { - expect(findToggleStar().exists()).toBe(true); - expect(findToggleStar().props('disabled')).toBe(false); - }); - - it('toggle star button is disabled when starring is taking place', () => { - store.commit(`monitoringDashboard/${types.REQUEST_DASHBOARD_STARRING}`); - - return wrapper.vm.$nextTick(() => { - expect(findToggleStar().exists()).toBe(true); - expect(findToggleStar().props('disabled')).toBe(true); - }); - }); - - describe('when the dashboard list is loaded', () => { - // Tooltip element should wrap directly - const getToggleTooltip = () => findToggleStar().element.parentElement.getAttribute('title'); - - beforeEach(() => { - setupAllDashboards(store); - jest.spyOn(store, 'dispatch'); - }); - - it('dispatches a toggle star action', () => { - findToggleStar().vm.$emit('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(store.dispatch).toHaveBeenCalledWith( - 'monitoringDashboard/toggleStarredValue', - undefined, - ); - }); - }); - - describe('when dashboard is not starred', () => { - beforeEach(() => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboardGitResponse[0].path, - }); - return wrapper.vm.$nextTick(); - }); - - it('toggle star button shows "Star dashboard"', () => { - expect(getToggleTooltip()).toBe('Star dashboard'); - }); - - it('toggle star button shows an unstarred state', () => { - expect(findToggleStarIcon().attributes('name')).toBe('star-o'); - }); - }); - - describe('when dashboard is starred', () => { - beforeEach(() => { - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboardGitResponse[1].path, - }); - return wrapper.vm.$nextTick(); - }); - - it('toggle star button shows "Star dashboard"', () => { - expect(getToggleTooltip()).toBe('Unstar dashboard'); - }); - - it('toggle star button shows a starred state', () => { - expect(findToggleStarIcon().attributes('name')).toBe('star'); - }); - }); - }); - }); - - it('hides the environments dropdown list when there is no environments', () => { - createMountedWrapper({ hasMetrics: true }); - - setupStoreWithDashboard(store); - - return wrapper.vm.$nextTick().then(() => { - expect(findAllEnvironmentsDropdownItems()).toHaveLength(0); - }); - }); - - it('renders the datetimepicker dropdown', () => { - createMountedWrapper({ hasMetrics: true }); - - setupStoreWithData(store); - - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.find(DateTimePicker).exists()).toBe(true); - }); - }); - - it('renders the refresh dashboard button', () => { - createMountedWrapper({ hasMetrics: true }); - - setupStoreWithData(store); - - return wrapper.vm.$nextTick().then(() => { - const refreshBtn = wrapper.find(DashboardHeader).find(RefreshButton); - - expect(refreshBtn.exists()).toBe(true); - }); }); describe('variables section', () => { @@ -772,15 +566,6 @@ describe('Dashboard', () => { undefined, ); }); - - it('restores dashboard from full screen by typing the Escape key on IE11', () => { - mockKeyup(ESC_KEY_IE11); - - expect(store.dispatch).toHaveBeenCalledWith( - `monitoringDashboard/clearExpandedPanel`, - undefined, - ); - }); }); }); @@ -811,100 +596,6 @@ describe('Dashboard', () => { }); }); - describe('searchable environments dropdown', () => { - beforeEach(() => { - createMountedWrapper({ hasMetrics: true }, { attachToDocument: true }); - - setupStoreWithData(store); - - return wrapper.vm.$nextTick(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('renders a search input', () => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownSearch' }) - .exists(), - ).toBe(true); - }); - - it('renders dropdown items', () => { - findAllEnvironmentsDropdownItems().wrappers.forEach((itemWrapper, index) => { - const anchorEl = itemWrapper.find('a'); - if (anchorEl.exists()) { - expect(anchorEl.text()).toBe(environmentData[index].name); - } - }); - }); - - it('filters rendered dropdown items', () => { - const searchTerm = 'production'; - const resultEnvs = environmentData.filter(({ name }) => name.indexOf(searchTerm) !== -1); - setSearchTerm(searchTerm); - - return wrapper.vm.$nextTick().then(() => { - expect(findAllEnvironmentsDropdownItems().length).toEqual(resultEnvs.length); - }); - }); - - it('does not filter dropdown items if search term is empty string', () => { - const searchTerm = ''; - setSearchTerm(searchTerm); - - return wrapper.vm.$nextTick(() => { - expect(findAllEnvironmentsDropdownItems().length).toEqual(environmentData.length); - }); - }); - - it("shows error message if search term doesn't match", () => { - const searchTerm = 'does-not-exist'; - setSearchTerm(searchTerm); - - return wrapper.vm.$nextTick(() => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownMsg' }) - .isVisible(), - ).toBe(true); - }); - }); - - it('shows loading element when environments fetch is still loading', () => { - store.commit(`monitoringDashboard/${types.REQUEST_ENVIRONMENTS_DATA}`); - - return wrapper.vm - .$nextTick() - .then(() => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownLoading' }) - .exists(), - ).toBe(true); - }) - .then(() => { - store.commit( - `monitoringDashboard/${types.RECEIVE_ENVIRONMENTS_DATA_SUCCESS}`, - environmentData, - ); - }) - .then(() => { - expect( - wrapper - .find(DashboardHeader) - .find({ ref: 'monitorEnvironmentsDropdownLoading' }) - .exists(), - ).toBe(false); - }); - }); - }); - describe('drag and drop function', () => { const findDraggables = () => wrapper.findAll(VueDraggable); const findEnabledDraggables = () => findDraggables().filter(f => !f.attributes('disabled')); @@ -998,57 +689,6 @@ describe('Dashboard', () => { }); }); - describe('dashboard timezone', () => { - const setupWithTimezone = value => { - store = createStore({ dashboardTimezone: value }); - setupStoreWithData(store); - createShallowWrapper({ hasMetrics: true }); - return wrapper.vm.$nextTick; - }; - - describe('local timezone is enabled by default', () => { - beforeEach(() => { - return setupWithTimezone(); - }); - - it('shows the data time picker in local timezone', () => { - expect( - findDashboardHeader() - .find(DateTimePicker) - .props('utc'), - ).toBe(false); - }); - }); - - describe('when LOCAL timezone is enabled', () => { - beforeEach(() => { - return setupWithTimezone('LOCAL'); - }); - - it('shows the data time picker in local timezone', () => { - expect( - findDashboardHeader() - .find(DateTimePicker) - .props('utc'), - ).toBe(false); - }); - }); - - describe('when UTC timezone is enabled', () => { - beforeEach(() => { - return setupWithTimezone('UTC'); - }); - - it('shows the data time picker in UTC format', () => { - expect( - findDashboardHeader() - .find(DateTimePicker) - .props('utc'), - ).toBe(true); - }); - }); - }); - describe('cluster health', () => { beforeEach(() => { createShallowWrapper({ hasMetrics: true, showHeader: false }); @@ -1068,36 +708,9 @@ describe('Dashboard', () => { }); }); - describe('dashboard edit link', () => { - const findEditLink = () => wrapper.find('.js-edit-link'); - - beforeEach(() => { - createShallowWrapper({ hasMetrics: true }); - - setupAllDashboards(store); - return wrapper.vm.$nextTick(); - }); - - it('is not present for the default dashboard', () => { - expect(findEditLink().exists()).toBe(false); - }); - - it('is present for a custom dashboard, and links to its edit_path', () => { - const dashboard = dashboardGitResponse[1]; - store.commit(`monitoringDashboard/${types.SET_INITIAL_STATE}`, { - currentDashboard: dashboard.path, - }); - - return wrapper.vm.$nextTick().then(() => { - expect(findEditLink().exists()).toBe(true); - expect(findEditLink().attributes('href')).toBe(dashboard.project_blob_path); - }); - }); - }); - describe('document title', () => { const originalTitle = 'Original Title'; - const defaultDashboardName = dashboardGitResponse[0].display_name; + const overviewDashboardName = dashboardGitResponse[0].display_name; beforeEach(() => { document.title = originalTitle; @@ -1108,11 +721,11 @@ describe('Dashboard', () => { document.title = ''; }); - it('is prepended with default dashboard name by default', () => { + it('is prepended with the overview dashboard name by default', () => { setupAllDashboards(store); return wrapper.vm.$nextTick().then(() => { - expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true); + expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true); }); }); @@ -1127,11 +740,11 @@ describe('Dashboard', () => { }); }); - it('is prepended with default dashboard name is path is not known', () => { + it('is prepended with the overview dashboard name if path is not known', () => { setupAllDashboards(store, 'unknown/path'); return wrapper.vm.$nextTick().then(() => { - expect(document.title.startsWith(`${defaultDashboardName} · `)).toBe(true); + expect(document.title.startsWith(`${overviewDashboardName} · `)).toBe(true); }); }); @@ -1151,41 +764,6 @@ describe('Dashboard', () => { }); }); - describe('Dashboard dropdown', () => { - beforeEach(() => { - createMountedWrapper({ hasMetrics: true }); - setupAllDashboards(store); - return wrapper.vm.$nextTick(); - }); - - it('shows the dashboard dropdown', () => { - const dashboardDropdown = wrapper.find(DashboardsDropdown); - - expect(dashboardDropdown.exists()).toBe(true); - }); - }); - - describe('external dashboard link', () => { - beforeEach(() => { - createMountedWrapper({ - hasMetrics: true, - showPanels: false, - showTimeWindowDropdown: false, - externalDashboardUrl: '/mockUrl', - }); - - return wrapper.vm.$nextTick(); - }); - - it('shows the link', () => { - const externalDashboardButton = wrapper.find('.js-external-dashboard-link'); - - expect(externalDashboardButton.exists()).toBe(true); - expect(externalDashboardButton.is(GlDeprecatedButton)).toBe(true); - expect(externalDashboardButton.text()).toContain('View full dashboard'); - }); - }); - describe('Clipboard text in panels', () => { const currentDashboard = dashboardGitResponse[1].path; const panelIndex = 1; // skip expanded panel @@ -1243,74 +821,4 @@ describe('Dashboard', () => { expect(dashboardPanel.exists()).toBe(true); }); }); - - describe('add custom metrics', () => { - const findAddMetricButton = () => wrapper.find(DashboardHeader).find({ ref: 'addMetricBtn' }); - - describe('when not available', () => { - beforeEach(() => { - createShallowWrapper({ - hasMetrics: true, - customMetricsPath: '/endpoint', - }); - }); - it('does not render add button on the dashboard', () => { - expect(findAddMetricButton().exists()).toBe(false); - }); - }); - - describe('when available', () => { - let origPage; - beforeEach(done => { - jest.spyOn(Tracking, 'event').mockReturnValue(); - createShallowWrapper({ - hasMetrics: true, - customMetricsPath: '/endpoint', - customMetricsAvailable: true, - }); - setupStoreWithData(store); - - origPage = document.body.dataset.page; - document.body.dataset.page = 'projects:environments:metrics'; - - wrapper.vm.$nextTick(done); - }); - afterEach(() => { - document.body.dataset.page = origPage; - }); - - it('renders add button on the dashboard', () => { - expect(findAddMetricButton()).toBeDefined(); - }); - - it('uses modal for custom metrics form', () => { - expect(wrapper.find(GlModal).exists()).toBe(true); - expect(wrapper.find(GlModal).attributes().modalid).toBe('addMetric'); - }); - it('adding new metric is tracked', done => { - const submitButton = wrapper - .find(DashboardHeader) - .find({ ref: 'submitCustomMetricsFormBtn' }).vm; - wrapper.vm.$nextTick(() => { - submitButton.$el.click(); - wrapper.vm.$nextTick(() => { - expect(Tracking.event).toHaveBeenCalledWith( - document.body.dataset.page, - 'click_button', - { - label: 'add_new_metric', - property: 'modal', - value: undefined, - }, - ); - done(); - }); - }); - }); - - it('renders custom metrics form fields', () => { - expect(wrapper.find(CustomMetricsFormFields).exists()).toBe(true); - }); - }); - }); }); diff --git a/spec/frontend/monitoring/components/dashboard_url_time_spec.js b/spec/frontend/monitoring/components/dashboard_url_time_spec.js index 276e20bae6a..c4630bde32f 100644 --- a/spec/frontend/monitoring/components/dashboard_url_time_spec.js +++ b/spec/frontend/monitoring/components/dashboard_url_time_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { queryToObject, redirectTo, diff --git a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js index d09fcc92ee7..89adbad386f 100644 --- a/spec/frontend/monitoring/components/dashboards_dropdown_spec.js +++ b/spec/frontend/monitoring/components/dashboards_dropdown_spec.js @@ -1,12 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem, GlIcon } from '@gitlab/ui'; +import { GlNewDropdownItem, GlIcon } from '@gitlab/ui'; import DashboardsDropdown from '~/monitoring/components/dashboards_dropdown.vue'; -import { dashboardGitResponse, selfMonitoringDashboardGitResponse } from '../mock_data'; +import { dashboardGitResponse } from '../mock_data'; const defaultBranch = 'master'; -const modalId = 'duplicateDashboardModalId'; const starredDashboards = dashboardGitResponse.filter(({ starred }) => starred); const notStarredDashboards = dashboardGitResponse.filter(({ starred }) => !starred); @@ -17,20 +16,16 @@ describe('DashboardsDropdown', () => { function createComponent(props, opts = {}) { const storeOpts = { - methods: { - duplicateSystemDashboard: jest.fn(), - }, computed: { allDashboards: () => mockDashboards, selectedDashboard: () => mockSelectedDashboard, }, }; - return shallowMount(DashboardsDropdown, { + wrapper = shallowMount(DashboardsDropdown, { propsData: { ...props, defaultBranch, - modalId, }, sync: false, ...storeOpts, @@ -38,8 +33,8 @@ describe('DashboardsDropdown', () => { }); } - const findItems = () => wrapper.findAll(GlDropdownItem); - const findItemAt = i => wrapper.findAll(GlDropdownItem).at(i); + const findItems = () => wrapper.findAll(GlNewDropdownItem); + const findItemAt = i => wrapper.findAll(GlNewDropdownItem).at(i); const findSearchInput = () => wrapper.find({ ref: 'monitorDashboardsDropdownSearch' }); const findNoItemsMsg = () => wrapper.find({ ref: 'monitorDashboardsDropdownMsg' }); const findStarredListDivider = () => wrapper.find({ ref: 'starredListDivider' }); @@ -52,7 +47,7 @@ describe('DashboardsDropdown', () => { describe('when it receives dashboards data', () => { beforeEach(() => { - wrapper = createComponent(); + createComponent(); }); it('displays an item for each dashboard', () => { @@ -78,7 +73,7 @@ describe('DashboardsDropdown', () => { }); it('filters dropdown items when searched for item exists in the list', () => { - const searchTerm = 'Default'; + const searchTerm = 'Overview'; setSearchTerm(searchTerm); return wrapper.vm.$nextTick().then(() => { @@ -96,10 +91,22 @@ describe('DashboardsDropdown', () => { }); }); + describe('when a dashboard is selected', () => { + beforeEach(() => { + [mockSelectedDashboard] = starredDashboards; + createComponent(); + }); + + it('dashboard item is selected', () => { + expect(findItemAt(0).props('isChecked')).toBe(true); + expect(findItemAt(1).props('isChecked')).toBe(false); + }); + }); + describe('when the dashboard is missing a display name', () => { beforeEach(() => { mockDashboards = dashboardGitResponse.map(d => ({ ...d, display_name: undefined })); - wrapper = createComponent(); + createComponent(); }); it('displays items with the dashboard path, with starred dashboards first', () => { @@ -112,7 +119,7 @@ describe('DashboardsDropdown', () => { describe('when it receives starred dashboards', () => { beforeEach(() => { mockDashboards = starredDashboards; - wrapper = createComponent(); + createComponent(); }); it('displays an item for each dashboard', () => { @@ -133,7 +140,7 @@ describe('DashboardsDropdown', () => { describe('when it receives only not-starred dashboards', () => { beforeEach(() => { mockDashboards = notStarredDashboards; - wrapper = createComponent(); + createComponent(); }); it('displays an item for each dashboard', () => { @@ -150,90 +157,9 @@ describe('DashboardsDropdown', () => { }); }); - const duplicableCases = [ - dashboardGitResponse[0], - dashboardGitResponse[2], - selfMonitoringDashboardGitResponse[0], - ]; - - describe.each(duplicableCases)('when the selected dashboard can be duplicated', dashboard => { - let duplicateDashboardAction; - let modalDirective; - - beforeEach(() => { - mockSelectedDashboard = dashboard; - modalDirective = jest.fn(); - duplicateDashboardAction = jest.fn().mockResolvedValue(); - - wrapper = createComponent( - {}, - { - directives: { - GlModal: modalDirective, - }, - methods: { - // Mock vuex actions - duplicateSystemDashboard: duplicateDashboardAction, - }, - }, - ); - }); - - it('displays a dropdown item for each dashboard', () => { - expect(findItems().length).toEqual(dashboardGitResponse.length + 1); - }); - - it('displays one "duplicate dashboard" dropdown item with a directive attached', () => { - const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - - expect(item.length).toBe(1); - }); - - it('"duplicate dashboard" dropdown item directive works', () => { - const item = wrapper.find('[data-testid="duplicateDashboardItem"]'); - - item.trigger('click'); - - return wrapper.vm.$nextTick().then(() => { - expect(modalDirective).toHaveBeenCalled(); - }); - }); - - it('id is correct, as the value of modal directive binding matches modal id', () => { - expect(modalDirective).toHaveBeenCalledTimes(1); - - // Binding's second argument contains the modal id - expect(modalDirective.mock.calls[0][1]).toEqual( - expect.objectContaining({ - value: modalId, - }), - ); - }); - }); - - const nonDuplicableCases = [dashboardGitResponse[1], selfMonitoringDashboardGitResponse[1]]; - - describe.each(nonDuplicableCases)( - 'when the selected dashboard can not be duplicated', - dashboard => { - beforeEach(() => { - mockSelectedDashboard = dashboard; - - wrapper = createComponent(); - }); - - it('displays a dropdown list item for each dashboard, but no list item for "duplicate dashboard"', () => { - const item = wrapper.findAll('[data-testid="duplicateDashboardItem"]'); - - expect(findItems()).toHaveLength(dashboardGitResponse.length); - expect(item.length).toBe(0); - }); - }, - ); - describe('when a dashboard gets selected by the user', () => { beforeEach(() => { - wrapper = createComponent(); + createComponent(); findItemAt(1).vm.$emit('click'); }); diff --git a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js index 4e7fee81d66..74f265930b1 100644 --- a/spec/frontend/monitoring/components/embeds/metric_embed_spec.js +++ b/spec/frontend/monitoring/components/embeds/metric_embed_spec.js @@ -1,10 +1,10 @@ import { createLocalVue, shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; -import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import { TEST_HOST } from 'helpers/test_constants'; +import { setHTMLFixture } from 'helpers/fixtures'; +import DashboardPanel from '~/monitoring/components/dashboard_panel.vue'; import MetricEmbed from '~/monitoring/components/embeds/metric_embed.vue'; import { groups, initialState, metricsData, metricsWithData } from './mock_data'; -import { setHTMLFixture } from 'helpers/fixtures'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/monitoring/components/graph_group_spec.js b/spec/frontend/monitoring/components/graph_group_spec.js index 81f5d90c310..86e2523f708 100644 --- a/spec/frontend/monitoring/components/graph_group_spec.js +++ b/spec/frontend/monitoring/components/graph_group_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import GraphGroup from '~/monitoring/components/graph_group.vue'; import { GlLoadingIcon, GlIcon } from '@gitlab/ui'; +import GraphGroup from '~/monitoring/components/graph_group.vue'; describe('Graph group component', () => { let wrapper; diff --git a/spec/frontend/monitoring/components/group_empty_state_spec.js b/spec/frontend/monitoring/components/group_empty_state_spec.js index e8ef8192067..90bd6f67196 100644 --- a/spec/frontend/monitoring/components/group_empty_state_spec.js +++ b/spec/frontend/monitoring/components/group_empty_state_spec.js @@ -24,7 +24,7 @@ describe('GroupEmptyState', () => { 'FOO STATE', // does not fail with unknown states ]; - test.each(supportedStates)('Renders an empty state for %s', selectedState => { + it.each(supportedStates)('Renders an empty state for %s', selectedState => { const wrapper = createComponent({ selectedState }); expect(wrapper.element).toMatchSnapshot(); diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js index 29615638453..a9b8295f38e 100644 --- a/spec/frontend/monitoring/components/refresh_button_spec.js +++ b/spec/frontend/monitoring/components/refresh_button_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import { createStore } from '~/monitoring/stores'; +import Visibility from 'visibilityjs'; import { GlNewDropdown, GlNewDropdownItem, GlButton } from '@gitlab/ui'; - +import { createStore } from '~/monitoring/stores'; import RefreshButton from '~/monitoring/components/refresh_button.vue'; describe('RefreshButton', () => { @@ -10,8 +10,8 @@ describe('RefreshButton', () => { let dispatch; let documentHidden; - const createWrapper = () => { - wrapper = shallowMount(RefreshButton, { store }); + const createWrapper = (options = {}) => { + wrapper = shallowMount(RefreshButton, { store, ...options }); }; const findRefreshBtn = () => wrapper.find(GlButton); @@ -31,14 +31,8 @@ describe('RefreshButton', () => { jest.spyOn(store, 'dispatch').mockResolvedValue(); dispatch = store.dispatch; - // Document can be mock hidden by overriding the `hidden` property documentHidden = false; - Object.defineProperty(document, 'hidden', { - configurable: true, - get() { - return documentHidden; - }, - }); + jest.spyOn(Visibility, 'hidden').mockImplementation(() => documentHidden); createWrapper(); }); @@ -57,6 +51,20 @@ describe('RefreshButton', () => { expect(findDropdown().props('text')).toBe('Off'); }); + describe('when feature flag disable_metric_dashboard_refresh_rate is on', () => { + beforeEach(() => { + createWrapper({ + provide: { + glFeatures: { disableMetricDashboardRefreshRate: true }, + }, + }); + }); + + it('refresh rate is not available', () => { + expect(findDropdown().exists()).toBe(false); + }); + }); + describe('refresh rate options', () => { it('presents multiple options', () => { expect(findOptions().length).toBeGreaterThan(1); diff --git a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js index cc384aef231..788f3abf617 100644 --- a/spec/frontend/monitoring/components/variables/dropdown_field_spec.js +++ b/spec/frontend/monitoring/components/variables/dropdown_field_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import DropdownField from '~/monitoring/components/variables/dropdown_field.vue'; describe('Custom variable component', () => { @@ -23,8 +23,8 @@ describe('Custom variable component', () => { }); }; - const findDropdown = () => wrapper.find(GlDropdown); - const findDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findDropdown = () => wrapper.find(GlDeprecatedDropdown); + const findDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); it('renders dropdown element when all necessary props are passed', () => { createShallowWrapper(); diff --git a/spec/frontend/monitoring/csv_export_spec.js b/spec/frontend/monitoring/csv_export_spec.js new file mode 100644 index 00000000000..eb2a6e40243 --- /dev/null +++ b/spec/frontend/monitoring/csv_export_spec.js @@ -0,0 +1,126 @@ +import { timeSeriesGraphData } from './graph_data'; +import { graphDataToCsv } from '~/monitoring/csv_export'; + +describe('monitoring export_csv', () => { + describe('graphDataToCsv', () => { + const expectCsvToMatchLines = (csv, lines) => expect(`${lines.join('\r\n')}\r\n`).toEqual(csv); + + it('should return a csv with 0 metrics', () => { + const data = timeSeriesGraphData({}, { metricCount: 0 }); + + expect(graphDataToCsv(data)).toEqual(''); + }); + + it('should return a csv with 1 metric with no data', () => { + const data = timeSeriesGraphData({}, { metricCount: 1 }); + + // When state is NO_DATA, result is null + data.metrics[0].result = null; + + expect(graphDataToCsv(data)).toEqual(''); + }); + + it('should return a csv with 1 metric', () => { + const data = timeSeriesGraphData({}, { metricCount: 1 }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1"`, + '2015-07-01T20:10:50.000Z,1', + '2015-07-01T20:12:50.000Z,2', + '2015-07-01T20:14:50.000Z,3', + ]); + }); + + it('should return a csv with multiple metrics and one with no data', () => { + const data = timeSeriesGraphData({}, { metricCount: 2 }); + + // When state is NO_DATA, result is null + data.metrics[0].result = null; + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 2"`, + '2015-07-01T20:10:50.000Z,1', + '2015-07-01T20:12:50.000Z,2', + '2015-07-01T20:14:50.000Z,3', + ]); + }); + + it('should return a csv when not all metrics have the same timestamps', () => { + const data = timeSeriesGraphData({}, { metricCount: 3 }); + + // Add an "odd" timestamp that is not in the dataset + Object.assign(data.metrics[2].result[0], { + value: ['2016-01-01T00:00:00.000Z', 9], + values: [['2016-01-01T00:00:00.000Z', 9]], + }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1","Y Axis > Metric 2","Y Axis > Metric 3"`, + '2015-07-01T20:10:50.000Z,1,1,', + '2015-07-01T20:12:50.000Z,2,2,', + '2015-07-01T20:14:50.000Z,3,3,', + '2016-01-01T00:00:00.000Z,,,9', + ]); + }); + + it('should escape double quotes in metric labels with two double quotes ("")', () => { + const data = timeSeriesGraphData({}, { metricCount: 1 }); + + data.metrics[0].label = 'My "quoted" metric'; + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > My ""quoted"" metric"`, + '2015-07-01T20:10:50.000Z,1', + '2015-07-01T20:12:50.000Z,2', + '2015-07-01T20:14:50.000Z,3', + ]); + }); + + it('should return a csv with multiple metrics', () => { + const data = timeSeriesGraphData({}, { metricCount: 3 }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1","Y Axis > Metric 2","Y Axis > Metric 3"`, + '2015-07-01T20:10:50.000Z,1,1,1', + '2015-07-01T20:12:50.000Z,2,2,2', + '2015-07-01T20:14:50.000Z,3,3,3', + ]); + }); + + it('should return a csv with 1 metric and multiple series with labels', () => { + const data = timeSeriesGraphData({}, { isMultiSeries: true }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > Metric 1","Y Axis > Metric 1"`, + '2015-07-01T20:10:50.000Z,1,4', + '2015-07-01T20:12:50.000Z,2,5', + '2015-07-01T20:14:50.000Z,3,6', + ]); + }); + + it('should return a csv with 1 metric and multiple series', () => { + const data = timeSeriesGraphData({}, { isMultiSeries: true, withLabels: false }); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091"`, + '2015-07-01T20:10:50.000Z,1,4', + '2015-07-01T20:12:50.000Z,2,5', + '2015-07-01T20:14:50.000Z,3,6', + ]); + }); + + it('should return a csv with multiple metrics and multiple series', () => { + const data = timeSeriesGraphData( + {}, + { metricCount: 3, isMultiSeries: true, withLabels: false }, + ); + + expectCsvToMatchLines(graphDataToCsv(data), [ + `timestamp,"Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091","Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091","Y Axis > __name__: up, job: prometheus, instance: localhost:9090","Y Axis > __name__: up, job: node, instance: localhost:9091"`, + '2015-07-01T20:10:50.000Z,1,4,1,4,1,4', + '2015-07-01T20:12:50.000Z,2,5,2,5,2,5', + '2015-07-01T20:14:50.000Z,3,6,3,6,3,6', + ]); + }); + }); +}); diff --git a/spec/frontend/monitoring/fixture_data.js b/spec/frontend/monitoring/fixture_data.js index 97edf7bda74..30040d3f89f 100644 --- a/spec/frontend/monitoring/fixture_data.js +++ b/spec/frontend/monitoring/fixture_data.js @@ -29,36 +29,12 @@ const datasetState = stateAndPropsFromDataset( // https://gitlab.com/gitlab-org/gitlab/-/issues/229256 export const dashboardProps = { ...datasetState.dataProps, - addDashboardDocumentationPath: 'https://path/to/docs', alertsEndpoint: null, }; export const metricsDashboardViewModel = mapToDashboardViewModel(metricsDashboardPayload); export const metricsDashboardPanelCount = 22; -export const metricResultStatus = { - // First metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` - metricId: 'NO_DB_response_metrics_nginx_ingress_throughput_status_code', - data: { - resultType: 'matrix', - result: metricsResult, - }, -}; -export const metricResultPods = { - // Second metric in fixture `metrics_dashboard/environment_metrics_dashboard.json` - metricId: 'NO_DB_response_metrics_nginx_ingress_latency_pod_average', - data: { - resultType: 'matrix', - result: metricsResult, - }, -}; -export const metricResultEmpty = { - metricId: 'NO_DB_response_metrics_nginx_ingress_16_throughput_status_code', - data: { - resultType: 'matrix', - result: [], - }, -}; // Graph data diff --git a/spec/frontend/monitoring/graph_data.js b/spec/frontend/monitoring/graph_data.js index e1b95723f3d..f85351e55d7 100644 --- a/spec/frontend/monitoring/graph_data.js +++ b/spec/frontend/monitoring/graph_data.js @@ -1,10 +1,38 @@ import { mapPanelToViewModel, normalizeQueryResponseData } from '~/monitoring/stores/utils'; import { panelTypes, metricStates } from '~/monitoring/constants'; -const initTime = 1435781451.781; +const initTime = 1435781450; // "Wed, 01 Jul 2015 20:10:50 GMT" +const intervalSeconds = 120; const makeValue = val => [initTime, val]; -const makeValues = vals => vals.map((val, i) => [initTime + 15 * i, val]); +const makeValues = vals => vals.map((val, i) => [initTime + intervalSeconds * i, val]); + +// Raw Promethues Responses + +export const prometheusMatrixMultiResult = ({ + values1 = ['1', '2', '3'], + values2 = ['4', '5', '6'], +} = {}) => ({ + resultType: 'matrix', + result: [ + { + metric: { + __name__: 'up', + job: 'prometheus', + instance: 'localhost:9090', + }, + values: makeValues(values1), + }, + { + metric: { + __name__: 'up', + job: 'node', + instance: 'localhost:9091', + }, + values: makeValues(values2), + }, + ], +}); // Normalized Prometheus Responses @@ -82,7 +110,7 @@ const matrixMultiResult = ({ values1 = ['1', '2', '3'], values2 = ['4', '5', '6' * @param {Object} dataOptions.isMultiSeries */ export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { - const { metricCount = 1, isMultiSeries = false } = dataOptions; + const { metricCount = 1, isMultiSeries = false, withLabels = true } = dataOptions; return mapPanelToViewModel({ title: 'Time Series Panel', @@ -90,7 +118,7 @@ export const timeSeriesGraphData = (panelOptions = {}, dataOptions = {}) => { x_label: 'X Axis', y_label: 'Y Axis', metrics: Array.from(Array(metricCount), (_, i) => ({ - label: `Metric ${i + 1}`, + label: withLabels ? `Metric ${i + 1}` : undefined, state: metricStates.OK, result: isMultiSeries ? matrixMultiResult() : matrixSingleResult(), })), @@ -162,3 +190,59 @@ export const anomalyGraphData = (panelOptions = {}, dataOptions = {}) => { ...panelOptions, }); }; + +/** + * Generate mock graph data for heatmaps according to options + */ +export const heatmapGraphData = (panelOptions = {}, dataOptions = {}) => { + const { metricCount = 1 } = dataOptions; + + return mapPanelToViewModel({ + title: 'Heatmap Panel', + type: panelTypes.HEATMAP, + x_label: 'X Axis', + y_label: 'Y Axis', + metrics: Array.from(Array(metricCount), (_, i) => ({ + label: `Metric ${i + 1}`, + state: metricStates.OK, + result: matrixMultiResult(), + })), + ...panelOptions, + }); +}; + +/** + * Generate gauge chart mock graph data according to options + * + * @param {Object} panelOptions - Panel options as in YML. + * + */ +export const gaugeChartGraphData = (panelOptions = {}) => { + const { + minValue = 100, + maxValue = 1000, + split = 20, + thresholds = { + mode: 'absolute', + values: [500, 800], + }, + format = 'kilobytes', + } = panelOptions; + + return mapPanelToViewModel({ + title: 'Gauge Chart Panel', + type: panelTypes.GAUGE_CHART, + min_value: minValue, + max_value: maxValue, + split, + thresholds, + format, + metrics: [ + { + label: `Metric`, + state: metricStates.OK, + result: matrixSingleResult(), + }, + ], + }); +}; diff --git a/spec/frontend/monitoring/mock_data.js b/spec/frontend/monitoring/mock_data.js index 49ad33402c6..28a7dd1af4f 100644 --- a/spec/frontend/monitoring/mock_data.js +++ b/spec/frontend/monitoring/mock_data.js @@ -1,3 +1,4 @@ +import invalidUrl from '~/lib/utils/invalid_url'; // This import path needs to be relative for now because this mock data is used in // Karma specs too, where the helpers/test_constants alias can not be resolved import { TEST_HOST } from '../helpers/test_constants'; @@ -170,7 +171,7 @@ export const environmentData = [ export const dashboardGitResponse = [ { default: true, - display_name: 'Default', + display_name: 'Overview', can_edit: false, system_dashboard: true, out_of_the_box_dashboard: true, @@ -209,7 +210,7 @@ export const selfMonitoringDashboardGitResponse = [ default: true, display_name: 'Default', can_edit: false, - system_dashboard: false, + system_dashboard: true, out_of_the_box_dashboard: true, project_blob_path: null, path: 'config/prometheus/self_monitoring_default.yml', @@ -244,83 +245,6 @@ export const metricsResult = [ }, ]; -export const graphDataPrometheusQueryRangeMultiTrack = { - title: 'Super Chart A3', - type: 'heatmap', - weight: 3, - x_label: 'Status Code', - y_label: 'Time', - metrics: [ - { - metricId: '1_metric_b', - id: 'response_metrics_nginx_ingress_throughput_status_code', - query_range: - 'sum(rate(nginx_upstream_responses_total{upstream=~"%{kube_namespace}-%{ci_environment_slug}-.*"}[60m])) by (status_code)', - unit: 'req / sec', - label: 'Status Code', - prometheus_endpoint_path: - '/root/rails_nodb/environments/3/prometheus/api/v1/query_range?query=sum%28rate%28nginx_upstream_responses_total%7Bupstream%3D~%22%25%7Bkube_namespace%7D-%25%7Bci_environment_slug%7D-.%2A%22%7D%5B2m%5D%29%29+by+%28status_code%29', - result: [ - { - metric: { status_code: '1xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 0], - ['2019-08-30T16:00:00.000Z', 2], - ['2019-08-30T17:00:00.000Z', 0], - ['2019-08-30T18:00:00.000Z', 0], - ['2019-08-30T19:00:00.000Z', 0], - ['2019-08-30T20:00:00.000Z', 3], - ], - }, - { - metric: { status_code: '2xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 1], - ['2019-08-30T16:00:00.000Z', 3], - ['2019-08-30T17:00:00.000Z', 6], - ['2019-08-30T18:00:00.000Z', 10], - ['2019-08-30T19:00:00.000Z', 8], - ['2019-08-30T20:00:00.000Z', 6], - ], - }, - { - metric: { status_code: '3xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 1], - ['2019-08-30T16:00:00.000Z', 2], - ['2019-08-30T17:00:00.000Z', 3], - ['2019-08-30T18:00:00.000Z', 3], - ['2019-08-30T19:00:00.000Z', 2], - ['2019-08-30T20:00:00.000Z', 1], - ], - }, - { - metric: { status_code: '4xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 2], - ['2019-08-30T16:00:00.000Z', 0], - ['2019-08-30T17:00:00.000Z', 0], - ['2019-08-30T18:00:00.000Z', 2], - ['2019-08-30T19:00:00.000Z', 0], - ['2019-08-30T20:00:00.000Z', 2], - ], - }, - { - metric: { status_code: '5xx' }, - values: [ - ['2019-08-30T15:00:00.000Z', 0], - ['2019-08-30T16:00:00.000Z', 1], - ['2019-08-30T17:00:00.000Z', 0], - ['2019-08-30T18:00:00.000Z', 0], - ['2019-08-30T19:00:00.000Z', 0], - ['2019-08-30T20:00:00.000Z', 2], - ], - }, - ], - }, - ], -}; - export const stackedColumnMockedData = { title: 'memories', type: 'stacked-column', @@ -420,6 +344,11 @@ export const mockNamespaces = [`${baseNamespace}/1`, `${baseNamespace}/2`]; export const mockTimeRange = { duration: { seconds: 120 } }; +export const mockFixedTimeRange = { + start: '2020-06-17T19:59:08.659Z', + end: '2020-07-17T19:59:08.659Z', +}; + export const mockNamespacedData = { mockDeploymentData: ['mockDeploymentData'], mockProjectPath: '/mockProjectPath', @@ -688,10 +617,28 @@ export const storeVariables = [ export const dashboardHeaderProps = { defaultBranch: 'master', - addDashboardDocumentationPath: 'https://path/to/docs', isRearrangingPanels: false, selectedTimeRange: { start: '2020-01-01T00:00:00.000Z', end: '2020-01-01T01:00:00.000Z', }, }; + +export const dashboardActionsMenuProps = { + defaultBranch: 'master', + addingMetricsAvailable: true, + customMetricsPath: 'https://path/to/customMetrics', + validateQueryPath: 'https://path/to/validateQuery', + isOotbDashboard: true, +}; + +export const mockAlert = { + alert_path: 'alert_path', + id: 8, + metricId: 'mock_metric_id', + operator: '>', + query: 'testQuery', + runbookUrl: invalidUrl, + threshold: 5, + title: 'alert title', +}; diff --git a/spec/frontend/monitoring/pages/panel_new_page_spec.js b/spec/frontend/monitoring/pages/panel_new_page_spec.js new file mode 100644 index 00000000000..83365b754d9 --- /dev/null +++ b/spec/frontend/monitoring/pages/panel_new_page_spec.js @@ -0,0 +1,98 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { DASHBOARD_PAGE, PANEL_NEW_PAGE } from '~/monitoring/router/constants'; +import { createStore } from '~/monitoring/stores'; +import DashboardPanelBuilder from '~/monitoring/components/dashboard_panel_builder.vue'; + +import PanelNewPage from '~/monitoring/pages/panel_new_page.vue'; + +const dashboard = 'dashboard.yml'; + +// Button stub that can accept `to` as router links do +// https://bootstrap-vue.org/docs/components/button#comp-ref-b-button-props +const GlButtonStub = { + extends: GlButton, + props: { + to: [String, Object], + }, +}; + +describe('monitoring/pages/panel_new_page', () => { + let store; + let wrapper; + let $route; + let $router; + + const mountComponent = (propsData = {}, route) => { + $route = route ?? { name: PANEL_NEW_PAGE, params: { dashboard } }; + $router = { + push: jest.fn(), + }; + + wrapper = shallowMount(PanelNewPage, { + propsData, + store, + stubs: { + GlButton: GlButtonStub, + }, + mocks: { + $router, + $route, + }, + }); + }; + + const findBackButton = () => wrapper.find(GlButtonStub); + const findPanelBuilder = () => wrapper.find(DashboardPanelBuilder); + + beforeEach(() => { + store = createStore(); + mountComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('back to dashboard button', () => { + it('is rendered', () => { + expect(findBackButton().exists()).toBe(true); + expect(findBackButton().props('icon')).toBe('go-back'); + }); + + it('links back to the dashboard', () => { + expect(findBackButton().props('to')).toEqual({ + name: DASHBOARD_PAGE, + params: { dashboard }, + }); + }); + + it('links back to the dashboard while preserving query params', () => { + $route = { + name: PANEL_NEW_PAGE, + params: { dashboard }, + query: { another: 'param' }, + }; + + mountComponent({}, $route); + + expect(findBackButton().props('to')).toEqual({ + name: DASHBOARD_PAGE, + params: { dashboard }, + query: { another: 'param' }, + }); + }); + }); + + describe('dashboard panel builder', () => { + it('is rendered', () => { + expect(findPanelBuilder().exists()).toBe(true); + }); + }); + + describe('page routing', () => { + it('route is not updated by default', () => { + expect($router.push).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/monitoring/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js new file mode 100644 index 00000000000..a91c209875a --- /dev/null +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -0,0 +1,149 @@ +import MockAdapter from 'axios-mock-adapter'; +import { backoffMockImplementation } from 'jest/helpers/backoff_helper'; +import axios from '~/lib/utils/axios_utils'; +import statusCodes from '~/lib/utils/http_status'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { metricsDashboardResponse } from '../fixture_data'; +import { getDashboard, getPrometheusQueryData } from '~/monitoring/requests'; + +describe('monitoring metrics_requests', () => { + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); + }); + + afterEach(() => { + mock.reset(); + + commonUtils.backOff.mockReset(); + }); + + describe('getDashboard', () => { + const response = metricsDashboardResponse; + const dashboardEndpoint = '/dashboard'; + const params = { + start_time: 'start_time', + end_time: 'end_time', + }; + + it('returns a dashboard response', () => { + mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); + + return getDashboard(dashboardEndpoint, params).then(data => { + expect(data).toEqual(metricsDashboardResponse); + }); + }); + + it('returns a dashboard response after retrying twice', () => { + mock.onGet(dashboardEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(dashboardEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); + + return getDashboard(dashboardEndpoint, params).then(data => { + expect(data).toEqual(metricsDashboardResponse); + expect(mock.history.get).toHaveLength(3); + }); + }); + + it('rejects after getting an error', () => { + mock.onGet(dashboardEndpoint).reply(500); + + return getDashboard(dashboardEndpoint, params).catch(error => { + expect(error).toEqual(expect.any(Error)); + expect(mock.history.get).toHaveLength(1); + }); + }); + }); + + describe('getPrometheusQueryData', () => { + const response = { + status: 'success', + data: { + resultType: 'matrix', + result: [], + }, + }; + const prometheusEndpoint = '/query_range'; + const params = { + start_time: 'start_time', + end_time: 'end_time', + }; + + it('returns a dashboard response', () => { + mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); + + return getPrometheusQueryData(prometheusEndpoint, params).then(data => { + expect(data).toEqual(response.data); + }); + }); + + it('returns a dashboard response after retrying twice', () => { + // Mock multiple attempts while the cache is filling up + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); // 3rd attempt + + return getPrometheusQueryData(prometheusEndpoint, params).then(data => { + expect(data).toEqual(response.data); + expect(mock.history.get).toHaveLength(3); + }); + }); + + it('rejects after getting an HTTP 500 error', () => { + mock.onGet(prometheusEndpoint).reply(500, { + status: 'error', + error: 'An error ocurred', + }); + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error('Request failed with status code 500')); + }); + }); + + it('rejects after retrying twice and getting an HTTP 401 error', () => { + // Mock multiple attempts while the cache is filling up and fails + mock.onGet(prometheusEndpoint).reply(statusCodes.UNAUTHORIZED, { + status: 'error', + error: 'An error ocurred', + }); + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error('Request failed with status code 401')); + }); + }); + + it('rejects after retrying twice and getting an HTTP 500 error', () => { + // Mock multiple attempts while the cache is filling up and fails + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).replyOnce(statusCodes.NO_CONTENT); + mock.onGet(prometheusEndpoint).reply(500, { + status: 'error', + error: 'An error ocurred', + }); // 3rd attempt + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error('Request failed with status code 500')); + expect(mock.history.get).toHaveLength(3); + }); + }); + + test.each` + code | reason + ${statusCodes.BAD_REQUEST} | ${'Parameters are missing or incorrect'} + ${statusCodes.UNPROCESSABLE_ENTITY} | ${"Expression can't be executed"} + ${statusCodes.SERVICE_UNAVAILABLE} | ${'Query timed out or aborted'} + `('rejects with details: "$reason" after getting an HTTP $code error', ({ code, reason }) => { + mock.onGet(prometheusEndpoint).reply(code, { + status: 'error', + error: reason, + }); + + return getPrometheusQueryData(prometheusEndpoint, params).catch(error => { + expect(error).toEqual(new Error(reason)); + expect(mock.history.get).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/monitoring/router_spec.js b/spec/frontend/monitoring/router_spec.js index 5b8f4b3c83e..8b97c8ed125 100644 --- a/spec/frontend/monitoring/router_spec.js +++ b/spec/frontend/monitoring/router_spec.js @@ -1,18 +1,28 @@ import { mount, createLocalVue } from '@vue/test-utils'; import VueRouter from 'vue-router'; import DashboardPage from '~/monitoring/pages/dashboard_page.vue'; +import PanelNewPage from '~/monitoring/pages/panel_new_page.vue'; import Dashboard from '~/monitoring/components/dashboard.vue'; import { createStore } from '~/monitoring/stores'; import createRouter from '~/monitoring/router'; import { dashboardProps } from './fixture_data'; import { dashboardHeaderProps } from './mock_data'; +const LEGACY_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics'; +const BASE_PATH = '/project/my-group/test-project/-/metrics'; + +const MockApp = { + data() { + return { + dashboardProps: { ...dashboardProps, ...dashboardHeaderProps }, + }; + }, + template: `<router-view :dashboard-props="dashboardProps"/>`, +}; + describe('Monitoring router', () => { let router; let store; - const propsData = { dashboardProps: { ...dashboardProps, ...dashboardHeaderProps } }; - const NEW_BASE_PATH = '/project/my-group/test-project/-/metrics'; - const OLD_BASE_PATH = '/project/my-group/test-project/-/environments/71146/metrics'; const createWrapper = (basePath, routeArg) => { const localVue = createLocalVue(); @@ -23,11 +33,10 @@ describe('Monitoring router', () => { router.push(routeArg); } - return mount(DashboardPage, { + return mount(MockApp, { localVue, store, router, - propsData, }); }; @@ -40,26 +49,32 @@ describe('Monitoring router', () => { window.location.hash = ''; }); - describe('support old URL with full dashboard path', () => { + describe('support legacy URLs with full dashboard path to visit dashboard page', () => { it.each` - route | currentDashboard + path | currentDashboard ${'/dashboard.yml'} | ${'dashboard.yml'} ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} ${'/?dashboard=dashboard.yml'} | ${'dashboard.yml'} - `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => { - const wrapper = createWrapper(OLD_BASE_PATH, route); + `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { + const wrapper = createWrapper(LEGACY_BASE_PATH, path); expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { currentDashboard, }); - expect(wrapper.find(Dashboard)).toExist(); + expect(wrapper.find(DashboardPage).exists()).toBe(true); + expect( + wrapper + .find(DashboardPage) + .find(Dashboard) + .exists(), + ).toBe(true); }); }); - describe('supports new URL with short dashboard path', () => { + describe('supports URLs to visit dashboard page', () => { it.each` - route | currentDashboard + path | currentDashboard ${'/'} | ${null} ${'/dashboard.yml'} | ${'dashboard.yml'} ${'/folder1/dashboard.yml'} | ${'folder1/dashboard.yml'} @@ -68,14 +83,35 @@ describe('Monitoring router', () => { ${'/config/prometheus/common_metrics.yml'} | ${'config/prometheus/common_metrics.yml'} ${'/config/prometheus/pod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} ${'/config%2Fprometheus%2Fpod_metrics.yml'} | ${'config/prometheus/pod_metrics.yml'} - `('sets component as $componentName for path "$route"', ({ route, currentDashboard }) => { - const wrapper = createWrapper(NEW_BASE_PATH, route); + `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { + const wrapper = createWrapper(BASE_PATH, path); expect(store.dispatch).toHaveBeenCalledWith('monitoringDashboard/setCurrentDashboard', { currentDashboard, }); - expect(wrapper.find(Dashboard)).toExist(); + expect(wrapper.find(DashboardPage).exists()).toBe(true); + expect( + wrapper + .find(DashboardPage) + .find(Dashboard) + .exists(), + ).toBe(true); + }); + }); + + describe('supports URLs to visit new panel page', () => { + it.each` + path | currentDashboard + ${'/panel/new'} | ${undefined} + ${'/dashboard.yml/panel/new'} | ${'dashboard.yml'} + ${'/config/prometheus/common_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} + ${'/config%2Fprometheus%2Fcommon_metrics.yml/panel/new'} | ${'config/prometheus/common_metrics.yml'} + `('"$path" renders page with dashboard "$currentDashboard"', ({ path, currentDashboard }) => { + const wrapper = createWrapper(BASE_PATH, path); + + expect(wrapper.vm.$route.params.dashboard).toBe(currentDashboard); + expect(wrapper.find(PanelNewPage).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index 22f2b2e3c77..5c7ab4e6a1f 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -1,10 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; +import { backoffMockImplementation } from 'jest/helpers/backoff_helper'; import Tracking from '~/tracking'; import axios from '~/lib/utils/axios_utils'; import statusCodes from '~/lib/utils/http_status'; import * as commonUtils from '~/lib/utils/common_utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { defaultTimeRange } from '~/vue_shared/constants'; import * as getters from '~/monitoring/stores/getters'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; @@ -30,6 +31,7 @@ import { duplicateSystemDashboard, updateVariablesAndFetchData, fetchVariableMetricLabelValues, + fetchPanelPreview, } from '~/monitoring/stores/actions'; import { gqClient, @@ -73,19 +75,7 @@ describe('Monitoring store actions', () => { commit = jest.fn(); dispatch = jest.fn(); - jest.spyOn(commonUtils, 'backOff').mockImplementation(callback => { - const q = new Promise((resolve, reject) => { - const stop = arg => (arg instanceof Error ? reject(arg) : resolve(arg)); - const next = () => callback(next, stop); - // Define a timeout based on a mock timer - setTimeout(() => { - callback(next, stop); - }); - }); - // Run all resolved promises in chain - jest.runOnlyPendingTimers(); - return q; - }); + jest.spyOn(commonUtils, 'backOff').mockImplementation(backoffMockImplementation); }); afterEach(() => { @@ -483,7 +473,6 @@ describe('Monitoring store actions', () => { ], [], () => { - expect(mock.history.get).toHaveLength(1); done(); }, ).catch(done.fail); @@ -569,46 +558,8 @@ describe('Monitoring store actions', () => { }); }); - it('commits result, when waiting for results', done => { - // Mock multiple attempts while the cache is filling up - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).reply(200, { data }); // 4th attempt - - testAction( - fetchPrometheusMetric, - { metric, defaultQueryParams }, - state, - [ - { - type: types.REQUEST_METRIC_RESULT, - payload: { - metricId: metric.metricId, - }, - }, - { - type: types.RECEIVE_METRIC_RESULT_SUCCESS, - payload: { - metricId: metric.metricId, - data, - }, - }, - ], - [], - () => { - expect(mock.history.get).toHaveLength(4); - done(); - }, - ).catch(done.fail); - }); - it('commits failure, when waiting for results and getting a server error', done => { - // Mock multiple attempts while the cache is filling up and fails - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).replyOnce(statusCodes.NO_CONTENT); - mock.onGet(prometheusEndpointPath).reply(500); // 4th attempt + mock.onGet(prometheusEndpointPath).reply(500); const error = new Error('Request failed with status code 500'); @@ -633,7 +584,6 @@ describe('Monitoring store actions', () => { ], [], ).catch(e => { - expect(mock.history.get).toHaveLength(4); expect(e).toEqual(error); done(); }); @@ -1205,4 +1155,69 @@ describe('Monitoring store actions', () => { ); }); }); + + describe('fetchPanelPreview', () => { + const panelPreviewEndpoint = '/builder.json'; + const mockYmlContent = 'mock yml content'; + + beforeEach(() => { + state.panelPreviewEndpoint = panelPreviewEndpoint; + }); + + it('should not commit or dispatch if payload is empty', () => { + testAction(fetchPanelPreview, '', state, [], []); + }); + + it('should store the panel and fetch metric results', () => { + const mockPanel = { + title: 'Go heap size', + type: 'area-chart', + }; + + mock + .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) + .reply(statusCodes.OK, mockPanel); + + testAction( + fetchPanelPreview, + mockYmlContent, + state, + [ + { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, + { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, + { type: types.RECEIVE_PANEL_PREVIEW_SUCCESS, payload: mockPanel }, + ], + [{ type: 'fetchPanelPreviewMetrics' }], + ); + }); + + it('should display a validation error when the backend cannot process the yml', () => { + const mockErrorMsg = 'Each "metric" must define one of :query or :query_range'; + + mock + .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) + .reply(statusCodes.UNPROCESSABLE_ENTITY, { + message: mockErrorMsg, + }); + + testAction(fetchPanelPreview, mockYmlContent, state, [ + { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, + { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, + { type: types.RECEIVE_PANEL_PREVIEW_FAILURE, payload: mockErrorMsg }, + ]); + }); + + it('should display a generic error when the backend fails', () => { + mock.onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }).reply(500); + + testAction(fetchPanelPreview, mockYmlContent, state, [ + { type: types.SET_PANEL_PREVIEW_IS_SHOWN, payload: true }, + { type: types.REQUEST_PANEL_PREVIEW, payload: mockYmlContent }, + { + type: types.RECEIVE_PANEL_PREVIEW_FAILURE, + payload: 'Request failed with status code 500', + }, + ]); + }); + }); }); diff --git a/spec/frontend/monitoring/store/getters_spec.js b/spec/frontend/monitoring/store/getters_spec.js index a69f5265ea7..509de8a4596 100644 --- a/spec/frontend/monitoring/store/getters_spec.js +++ b/spec/frontend/monitoring/store/getters_spec.js @@ -11,37 +11,36 @@ import { storeVariables, mockLinks, } from '../mock_data'; -import { - metricsDashboardPayload, - metricResultStatus, - metricResultPods, - metricResultEmpty, -} from '../fixture_data'; +import { metricsDashboardPayload } from '../fixture_data'; describe('Monitoring store Getters', () => { + let state; + + const getMetric = ({ group = 0, panel = 0, metric = 0 } = {}) => + state.dashboard.panelGroups[group].panels[panel].metrics[metric]; + + const setMetricSuccess = ({ group, panel, metric, result = metricsResult } = {}) => { + const { metricId } = getMetric({ group, panel, metric }); + mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { + metricId, + data: { + resultType: 'matrix', + result, + }, + }); + }; + + const setMetricFailure = ({ group, panel, metric } = {}) => { + const { metricId } = getMetric({ group, panel, metric }); + mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { + metricId, + }); + }; + describe('getMetricStates', () => { let setupState; - let state; let getMetricStates; - const setMetricSuccess = ({ result = metricsResult, group = 0, panel = 0, metric = 0 }) => { - const { metricId } = state.dashboard.panelGroups[group].panels[panel].metrics[metric]; - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, { - metricId, - data: { - resultType: 'matrix', - result, - }, - }); - }; - - const setMetricFailure = ({ group = 0, panel = 0, metric = 0 }) => { - const { metricId } = state.dashboard.panelGroups[group].panels[panel].metrics[metric]; - mutations[types.RECEIVE_METRIC_RESULT_FAILURE](state, { - metricId, - }); - }; - beforeEach(() => { setupState = (initState = {}) => { state = initState; @@ -81,7 +80,7 @@ describe('Monitoring store Getters', () => { it('on an empty metric with no result, returns NO_DATA', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - setMetricSuccess({ result: [], group: 2 }); + setMetricSuccess({ group: 2, result: [] }); expect(getMetricStates()).toEqual([metricStates.NO_DATA]); }); @@ -147,7 +146,6 @@ describe('Monitoring store Getters', () => { describe('metricsWithData', () => { let metricsWithData; let setupState; - let state; beforeEach(() => { setupState = (initState = {}) => { @@ -191,35 +189,39 @@ describe('Monitoring store Getters', () => { it('an empty metric, returns empty', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultEmpty); + setMetricSuccess({ result: [] }); expect(metricsWithData()).toEqual([]); }); it('a metric with results, it returns a metric', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultStatus); + setMetricSuccess(); - expect(metricsWithData()).toEqual([metricResultStatus.metricId]); + expect(metricsWithData()).toEqual([getMetric().metricId]); }); it('multiple metrics with results, it return multiple metrics', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultStatus); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultPods); + setMetricSuccess({ panel: 0 }); + setMetricSuccess({ panel: 1 }); - expect(metricsWithData()).toEqual([metricResultStatus.metricId, metricResultPods.metricId]); + expect(metricsWithData()).toEqual([ + getMetric({ panel: 0 }).metricId, + getMetric({ panel: 1 }).metricId, + ]); }); it('multiple metrics with results, it returns metrics filtered by group', () => { mutations[types.RECEIVE_METRICS_DASHBOARD_SUCCESS](state, metricsDashboardPayload); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultStatus); - mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](state, metricResultPods); + + setMetricSuccess({ group: 1 }); + setMetricSuccess({ group: 1, panel: 1 }); // First group has metrics expect(metricsWithData(state.dashboard.panelGroups[1].key)).toEqual([ - metricResultStatus.metricId, - metricResultPods.metricId, + getMetric({ group: 1 }).metricId, + getMetric({ group: 1, panel: 1 }).metricId, ]); // Second group has no metrics @@ -229,7 +231,6 @@ describe('Monitoring store Getters', () => { }); describe('filteredEnvironments', () => { - let state; const setupState = (initState = {}) => { state = { ...state, @@ -284,7 +285,6 @@ describe('Monitoring store Getters', () => { describe('metricsSavedToDb', () => { let metricsSavedToDb; - let state; let mockData; beforeEach(() => { @@ -335,8 +335,6 @@ describe('Monitoring store Getters', () => { }); describe('getCustomVariablesParams', () => { - let state; - beforeEach(() => { state = { variables: {}, @@ -367,58 +365,65 @@ describe('Monitoring store Getters', () => { describe('selectedDashboard', () => { const { selectedDashboard } = getters; - const localGetters = state => ({ - fullDashboardPath: getters.fullDashboardPath(state), + const localGetters = localState => ({ + fullDashboardPath: getters.fullDashboardPath(localState), }); it('returns a dashboard', () => { - const state = { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[0].path, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[0], + ); }); - it('returns a non-default dashboard', () => { - const state = { + it('returns a dashboard different from the overview dashboard', () => { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: dashboardGitResponse[1].path, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[1]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[1], + ); }); - it('returns a default dashboard when no dashboard is selected', () => { - const state = { + it('returns the overview dashboard when no dashboard is selected', () => { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: null, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[0], + ); }); - it('returns a default dashboard when dashboard cannot be found', () => { - const state = { + it('returns the overview dashboard when dashboard cannot be found', () => { + const localState = { allDashboards: dashboardGitResponse, currentDashboard: 'wrong_path', customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(dashboardGitResponse[0]); + expect(selectedDashboard(localState, localGetters(localState))).toEqual( + dashboardGitResponse[0], + ); }); it('returns null when no dashboards are present', () => { - const state = { + const localState = { allDashboards: [], currentDashboard: dashboardGitResponse[0].path, customDashboardBasePath, }; - expect(selectedDashboard(state, localGetters(state))).toEqual(null); + expect(selectedDashboard(localState, localGetters(localState))).toEqual(null); }); }); describe('linksWithMetadata', () => { - let state; const setupState = (initState = {}) => { state = { ...state, diff --git a/spec/frontend/monitoring/store/mutations_spec.js b/spec/frontend/monitoring/store/mutations_spec.js index 14b38d79aa2..8d1351fc909 100644 --- a/spec/frontend/monitoring/store/mutations_spec.js +++ b/spec/frontend/monitoring/store/mutations_spec.js @@ -4,8 +4,8 @@ import mutations from '~/monitoring/stores/mutations'; import * as types from '~/monitoring/stores/mutation_types'; import state from '~/monitoring/stores/state'; import { dashboardEmptyStates, metricStates } from '~/monitoring/constants'; - import { deploymentData, dashboardGitResponse, storeTextVariables } from '../mock_data'; +import { prometheusMatrixMultiResult } from '../graph_data'; import { metricsDashboardPayload } from '../fixture_data'; describe('Monitoring mutations', () => { @@ -259,27 +259,6 @@ describe('Monitoring mutations', () => { describe('Individual panel/metric results', () => { const metricId = 'NO_DB_response_metrics_nginx_ingress_throughput_status_code'; - const data = { - resultType: 'matrix', - result: [ - { - metric: { - __name__: 'up', - job: 'prometheus', - instance: 'localhost:9090', - }, - values: [[1435781430.781, '1'], [1435781445.781, '1'], [1435781460.781, '1']], - }, - { - metric: { - __name__: 'up', - job: 'node', - instance: 'localhost:9091', - }, - values: [[1435781430.781, '0'], [1435781445.781, '0'], [1435781460.781, '1']], - }, - ], - }; const dashboard = metricsDashboardPayload; const getMetric = () => stateCopy.dashboard.panelGroups[1].panels[0].metrics[0]; @@ -307,6 +286,8 @@ describe('Monitoring mutations', () => { }); it('adds results to the store', () => { + const data = prometheusMatrixMultiResult(); + expect(getMetric().result).toBe(null); mutations[types.RECEIVE_METRIC_RESULT_SUCCESS](stateCopy, { @@ -488,4 +469,128 @@ describe('Monitoring mutations', () => { }); }); }); + + describe('REQUEST_PANEL_PREVIEW', () => { + it('saves yml content and resets other preview data', () => { + const mockYmlContent = 'mock yml content'; + mutations[types.REQUEST_PANEL_PREVIEW](stateCopy, mockYmlContent); + + expect(stateCopy.panelPreviewIsLoading).toBe(true); + expect(stateCopy.panelPreviewYml).toBe(mockYmlContent); + expect(stateCopy.panelPreviewGraphData).toBe(null); + expect(stateCopy.panelPreviewError).toBe(null); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_SUCCESS', () => { + it('saves graph data', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_SUCCESS](stateCopy, { + title: 'My Title', + type: 'area-chart', + }); + + expect(stateCopy.panelPreviewIsLoading).toBe(false); + expect(stateCopy.panelPreviewGraphData).toMatchObject({ + title: 'My Title', + type: 'area-chart', + }); + expect(stateCopy.panelPreviewError).toBe(null); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_FAILURE', () => { + it('saves graph data', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_FAILURE](stateCopy, 'Error!'); + + expect(stateCopy.panelPreviewIsLoading).toBe(false); + expect(stateCopy.panelPreviewGraphData).toBe(null); + expect(stateCopy.panelPreviewError).toBe('Error!'); + }); + }); + + describe('panel preview metric', () => { + const getPreviewMetricAt = i => stateCopy.panelPreviewGraphData.metrics[i]; + + beforeEach(() => { + stateCopy.panelPreviewGraphData = { + title: 'Preview panel title', + metrics: [ + { + query: 'query', + }, + ], + }; + }); + + describe('REQUEST_PANEL_PREVIEW_METRIC_RESULT', () => { + it('sets the metric to loading for the first time', () => { + mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 }); + + expect(getPreviewMetricAt(0).loading).toBe(true); + expect(getPreviewMetricAt(0).state).toBe(metricStates.LOADING); + }); + + it('sets the metric to loading and keeps the result', () => { + getPreviewMetricAt(0).result = [[0, 1]]; + getPreviewMetricAt(0).state = metricStates.OK; + + mutations[types.REQUEST_PANEL_PREVIEW_METRIC_RESULT](stateCopy, { index: 0 }); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: true, + result: [[0, 1]], + state: metricStates.OK, + }); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS', () => { + it('saves the result in the metric', () => { + const data = prometheusMatrixMultiResult(); + + mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_SUCCESS](stateCopy, { + index: 0, + data, + }); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: false, + state: metricStates.OK, + result: expect.any(Array), + }); + expect(getPreviewMetricAt(0).result).toHaveLength(data.result.length); + }); + }); + + describe('RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE', () => { + it('stores an error in the metric', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, { + index: 0, + }); + + expect(getPreviewMetricAt(0).loading).toBe(false); + expect(getPreviewMetricAt(0).state).toBe(metricStates.UNKNOWN_ERROR); + expect(getPreviewMetricAt(0).result).toBe(null); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: false, + result: null, + state: metricStates.UNKNOWN_ERROR, + }); + }); + + it('stores a timeout error in a metric', () => { + mutations[types.RECEIVE_PANEL_PREVIEW_METRIC_RESULT_FAILURE](stateCopy, { + index: 0, + error: { message: 'BACKOFF_TIMEOUT' }, + }); + + expect(getPreviewMetricAt(0)).toMatchObject({ + loading: false, + result: null, + state: metricStates.TIMEOUT, + }); + }); + }); + }); }); diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 35ca6ba9b52..fd7d09f7f72 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -1,6 +1,6 @@ +import { TEST_HOST } from 'jest/helpers/test_constants'; import * as monitoringUtils from '~/monitoring/utils'; import * as urlUtils from '~/lib/utils/url_utility'; -import { TEST_HOST } from 'jest/helpers/test_constants'; import { mockProjectDir, barMockData } from './mock_data'; import { singleStatGraphData, anomalyGraphData } from './graph_data'; import { metricsDashboardViewModel, graphData } from './fixture_data'; diff --git a/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js new file mode 100644 index 00000000000..a886715ce4b --- /dev/null +++ b/spec/frontend/notebook/cells/output/html_sanitize_fixtures.js @@ -0,0 +1,114 @@ +export default [ + [ + 'protocol-based JS injection: simple, no spaces', + { + input: `<a href="javascript:alert('XSS');">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: simple, spaces before', + { + input: `<a href="javascript :alert('XSS');">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: simple, spaces after', + { + input: `<a href="javascript: alert('XSS');">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: simple, spaces before and after', + { + input: `<a href="javascript : alert('XSS');">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: preceding colon', + { + input: `<a href=":javascript:alert('XSS');">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: UTF-8 encoding', + { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: long UTF-8 encoding', + { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: long UTF-8 encoding without semicolons', + { + input: + '<a href=javascript:alert('XSS')>foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: hex encoding', + { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: long hex encoding', + { + input: '<a href="javascript:">foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: hex encoding without semicolons', + { + input: + '<a href=javascript:alert('XSS')>foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: null char', + { + input: '<a href=java\u0000script:alert("XSS")>foo</a>', + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: invalid URL char', + { input: '<img src=javascript:alert("XSS")>', output: '<img>' }, + ], + [ + 'protocol-based JS injection: Unicode', + { + input: `<a href="\u0001java\u0003script:alert('XSS')">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'protocol-based JS injection: spaces and entities', + { + input: `<a href="  javascript:alert('XSS');">foo</a>`, + output: '<a>foo</a>', + }, + ], + [ + 'img on error', + { + input: '<img src="x" onerror="alert(document.domain)" />', + output: '<img src="x">', + }, + ], + ['style tags are removed', { input: '<style>.foo {}</style> Foo', output: 'Foo' }], +]; diff --git a/spec/frontend/notebook/cells/output/html_sanitize_tests.js b/spec/frontend/notebook/cells/output/html_sanitize_tests.js deleted file mode 100644 index 74c48f04367..00000000000 --- a/spec/frontend/notebook/cells/output/html_sanitize_tests.js +++ /dev/null @@ -1,68 +0,0 @@ -export default { - 'protocol-based JS injection: simple, no spaces': { - input: '<a href="javascript:alert(\'XSS\');">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: simple, spaces before': { - input: '<a href="javascript :alert(\'XSS\');">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: simple, spaces after': { - input: '<a href="javascript: alert(\'XSS\');">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: simple, spaces before and after': { - input: '<a href="javascript : alert(\'XSS\');">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: preceding colon': { - input: '<a href=":javascript:alert(\'XSS\');">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: UTF-8 encoding': { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: long UTF-8 encoding': { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: long UTF-8 encoding without semicolons': { - input: - '<a href=javascript:alert('XSS')>foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: hex encoding': { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: long hex encoding': { - input: '<a href="javascript:">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: hex encoding without semicolons': { - input: - '<a href=javascript:alert('XSS')>foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: null char': { - input: '<a href=java\0script:alert("XSS")>foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: invalid URL char': { - input: '<img src=javascript:alert("XSS")>', - output: '<img>', - }, - 'protocol-based JS injection: Unicode': { - input: '<a href="\u0001java\u0003script:alert(\'XSS\')">foo</a>', - output: '<a>foo</a>', - }, - 'protocol-based JS injection: spaces and entities': { - input: '<a href="  javascript:alert(\'XSS\');">foo</a>', - output: '<a>foo</a>', - }, - 'img on error': { - input: '<img src="x" onerror="alert(document.domain)" />', - output: '<img src="x">', - }, -}; diff --git a/spec/frontend/notebook/cells/output/html_spec.js b/spec/frontend/notebook/cells/output/html_spec.js index 3ee404fb187..48d62d74a50 100644 --- a/spec/frontend/notebook/cells/output/html_spec.js +++ b/spec/frontend/notebook/cells/output/html_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; import htmlOutput from '~/notebook/cells/output/html.vue'; -import sanitizeTests from './html_sanitize_tests'; +import sanitizeTests from './html_sanitize_fixtures'; describe('html output cell', () => { function createComponent(rawCode) { @@ -15,17 +15,12 @@ describe('html output cell', () => { }).$mount(); } - describe('sanitizes output', () => { - Object.keys(sanitizeTests).forEach(key => { - it(key, () => { - const test = sanitizeTests[key]; - const vm = createComponent(test.input); - const outputEl = [...vm.$el.querySelectorAll('div')].pop(); + it.each(sanitizeTests)('sanitizes output for: %p', (name, { input, output }) => { + const vm = createComponent(input); + const outputEl = [...vm.$el.querySelectorAll('div')].pop(); - expect(outputEl.innerHTML).toEqual(test.output); + expect(outputEl.innerHTML).toEqual(output); - vm.$destroy(); - }); - }); + vm.$destroy(); }); }); diff --git a/spec/frontend/notebook/cells/output/index_spec.js b/spec/frontend/notebook/cells/output/index_spec.js index 2b1aa5317c5..b9a2dfb8f34 100644 --- a/spec/frontend/notebook/cells/output/index_spec.js +++ b/spec/frontend/notebook/cells/output/index_spec.js @@ -34,7 +34,7 @@ describe('Output component', () => { expect(vm.$el.querySelector('pre')).not.toBeNull(); }); - it('renders promot', () => { + it('renders prompt', () => { expect(vm.$el.querySelector('.prompt span')).not.toBeNull(); }); }); diff --git a/spec/frontend/notes/components/discussion_actions_spec.js b/spec/frontend/notes/components/discussion_actions_spec.js index 44dc148933c..3e1e43d0c6a 100644 --- a/spec/frontend/notes/components/discussion_actions_spec.js +++ b/spec/frontend/notes/components/discussion_actions_spec.js @@ -21,7 +21,7 @@ const createUnallowedNote = () => describe('DiscussionActions', () => { let wrapper; - const createComponentFactory = (shallow = true) => props => { + const createComponentFactory = (shallow = true) => (props, options) => { const store = createStore(); const mountFn = shallow ? shallowMount : mount; @@ -35,6 +35,11 @@ describe('DiscussionActions', () => { shouldShowJumpToNextDiscussion: true, ...props, }, + provide: { + glFeatures: { + hideJumpToNextUnresolvedInThreads: options?.hideJumpToNextUnresolvedInThreads, + }, + }, }); }; @@ -96,6 +101,13 @@ describe('DiscussionActions', () => { }); }); + it('does not render jump to next discussion button if feature flag is enabled', () => { + const createComponent = createComponentFactory(); + createComponent({}, { hideJumpToNextUnresolvedInThreads: true }); + + expect(wrapper.find(JumpToNextDiscussionButton).exists()).toBe(false); + }); + describe('events handling', () => { const createComponent = createComponentFactory(false); diff --git a/spec/frontend/notes/components/discussion_filter_spec.js b/spec/frontend/notes/components/discussion_filter_spec.js index 7f042c0e9de..9a7896475e6 100644 --- a/spec/frontend/notes/components/discussion_filter_spec.js +++ b/spec/frontend/notes/components/discussion_filter_spec.js @@ -1,8 +1,8 @@ -import createEventHub from '~/helpers/event_hub_factory'; import Vuex from 'vuex'; - import { createLocalVue, mount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'jest/helpers/test_constants'; +import createEventHub from '~/helpers/event_hub_factory'; import axios from '~/lib/utils/axios_utils'; import notesModule from '~/notes/stores/modules'; @@ -10,7 +10,6 @@ import DiscussionFilter from '~/notes/components/discussion_filter.vue'; import { DISCUSSION_FILTERS_DEFAULT_VALUE, DISCUSSION_FILTER_TYPES } from '~/notes/constants'; import { discussionFiltersMock, discussionMock } from '../mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; const localVue = createLocalVue(); diff --git a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js b/spec/frontend/notes/components/discussion_navigator_spec.js index e932133b869..122814b8e3f 100644 --- a/spec/frontend/notes/components/discussion_keyboard_navigator_spec.js +++ b/spec/frontend/notes/components/discussion_navigator_spec.js @@ -1,9 +1,11 @@ /* global Mousetrap */ import 'mousetrap'; +import Vue from 'vue'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import DiscussionKeyboardNavigator from '~/notes/components/discussion_keyboard_navigator.vue'; +import DiscussionNavigator from '~/notes/components/discussion_navigator.vue'; +import eventHub from '~/notes/event_hub'; -describe('notes/components/discussion_keyboard_navigator', () => { +describe('notes/components/discussion_navigator', () => { const localVue = createLocalVue(); let wrapper; @@ -11,7 +13,7 @@ describe('notes/components/discussion_keyboard_navigator', () => { let jumpToPreviousDiscussion; const createComponent = () => { - wrapper = shallowMount(DiscussionKeyboardNavigator, { + wrapper = shallowMount(DiscussionNavigator, { mixins: [ localVue.extend({ methods: { @@ -29,10 +31,29 @@ describe('notes/components/discussion_keyboard_navigator', () => { }); afterEach(() => { - wrapper.destroy(); + if (wrapper) { + wrapper.destroy(); + } wrapper = null; }); + describe('on create', () => { + let onSpy; + let vm; + + beforeEach(() => { + onSpy = jest.spyOn(eventHub, '$on'); + vm = new (Vue.extend(DiscussionNavigator))(); + }); + + it('listens for jumpToFirstUnresolvedDiscussion events', () => { + expect(onSpy).toHaveBeenCalledWith( + 'jumpToFirstUnresolvedDiscussion', + vm.jumpToFirstUnresolvedDiscussion, + ); + }); + }); + describe('on mount', () => { beforeEach(() => { createComponent(); @@ -52,11 +73,16 @@ describe('notes/components/discussion_keyboard_navigator', () => { }); describe('on destroy', () => { + let jumpFn; + beforeEach(() => { jest.spyOn(Mousetrap, 'unbind'); + jest.spyOn(eventHub, '$off'); createComponent(); + jumpFn = wrapper.vm.jumpToFirstUnresolvedDiscussion; + wrapper.destroy(); }); @@ -65,6 +91,10 @@ describe('notes/components/discussion_keyboard_navigator', () => { expect(Mousetrap.unbind).toHaveBeenCalledWith('p'); }); + it('unbinds event hub listeners', () => { + expect(eventHub.$off).toHaveBeenCalledWith('jumpToFirstUnresolvedDiscussion', jumpFn); + }); + it('does not call jumpToNextDiscussion when pressing `n`', () => { Mousetrap.trigger('n'); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index 5a10deefd09..8cc98f978c2 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import { getByRole } from '@testing-library/dom'; import '~/behaviors/markdown/render_gfm'; import { SYSTEM_NOTE } from '~/notes/constants'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; @@ -9,14 +10,20 @@ import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import createStore from '~/notes/stores'; import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; +const LINE_RANGE = {}; +const DISCUSSION_WITH_LINE_RANGE = { + ...discussionMock, + position: { + line_range: LINE_RANGE, + }, +}; + describe('DiscussionNotes', () => { + let store; let wrapper; - const createComponent = props => { - const store = createStore(); - store.dispatch('setNoteableData', noteableDataMock); - store.dispatch('setNotesData', notesDataMock); - + const getList = () => getByRole(wrapper.element, 'list'); + const createComponent = (props, features = {}) => { wrapper = shallowMount(DiscussionNotes, { store, propsData: { @@ -31,11 +38,21 @@ describe('DiscussionNotes', () => { slots: { 'avatar-badge': '<span class="avatar-badge-slot-content" />', }, + provide: { + glFeatures: { multilineComments: true, ...features }, + }, }); }; + beforeEach(() => { + store = createStore(); + store.dispatch('setNoteableData', noteableDataMock); + store.dispatch('setNotesData', notesDataMock); + }); + afterEach(() => { wrapper.destroy(); + wrapper = null; }); describe('rendering', () => { @@ -160,6 +177,26 @@ describe('DiscussionNotes', () => { }); }); + describe.each` + desc | props | features | event | expectedCalls + ${'with `discussion.position`'} | ${{ discussion: DISCUSSION_WITH_LINE_RANGE }} | ${{}} | ${'mouseenter'} | ${[['setSelectedCommentPositionHover', LINE_RANGE]]} + ${'with `discussion.position`'} | ${{ discussion: DISCUSSION_WITH_LINE_RANGE }} | ${{}} | ${'mouseleave'} | ${[['setSelectedCommentPositionHover']]} + ${'with `discussion.position`'} | ${{ discussion: DISCUSSION_WITH_LINE_RANGE }} | ${{ multilineComments: false }} | ${'mouseenter'} | ${[]} + ${'with `discussion.position`'} | ${{ discussion: DISCUSSION_WITH_LINE_RANGE }} | ${{ multilineComments: false }} | ${'mouseleave'} | ${[]} + ${'without `discussion.position`'} | ${{}} | ${{}} | ${'mouseenter'} | ${[]} + ${'without `discussion.position`'} | ${{}} | ${{}} | ${'mouseleave'} | ${[]} + `('$desc and features $features', ({ props, event, features, expectedCalls }) => { + beforeEach(() => { + createComponent(props, features); + jest.spyOn(store, 'dispatch'); + }); + + it(`calls store ${expectedCalls.length} times on ${event}`, () => { + getList().dispatchEvent(new MouseEvent(event)); + expect(store.dispatch.mock.calls).toEqual(expectedCalls); + }); + }); + describe('componentData', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js index e62fb5db2c0..4348445f7ca 100644 --- a/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js +++ b/spec/frontend/notes/components/discussion_resolve_with_issue_button_spec.js @@ -1,4 +1,4 @@ -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { shallowMount, createLocalVue } from '@vue/test-utils'; import { TEST_HOST } from 'spec/test_constants'; import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_issue_button.vue'; @@ -23,7 +23,7 @@ describe('ResolveWithIssueButton', () => { }); it('it should have a link with the provided link property as href', () => { - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); expect(button.attributes().href).toBe(url); }); diff --git a/spec/frontend/notes/components/note_actions_spec.js b/spec/frontend/notes/components/note_actions_spec.js index 5cc56cdefae..97d1752726b 100644 --- a/spec/frontend/notes/components/note_actions_spec.js +++ b/spec/frontend/notes/components/note_actions_spec.js @@ -1,10 +1,10 @@ import Vue from 'vue'; import { shallowMount, createLocalVue, createWrapper } from '@vue/test-utils'; import { TEST_HOST } from 'spec/test_constants'; +import AxiosMockAdapter from 'axios-mock-adapter'; import createStore from '~/notes/stores'; import noteActions from '~/notes/components/note_actions.vue'; import { userDataMock } from '../mock_data'; -import AxiosMockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; describe('noteActions', () => { diff --git a/spec/frontend/notes/components/note_awards_list_spec.js b/spec/frontend/notes/components/note_awards_list_spec.js index 822b1f9efce..dce5424f154 100644 --- a/spec/frontend/notes/components/note_awards_list_spec.js +++ b/spec/frontend/notes/components/note_awards_list_spec.js @@ -1,10 +1,10 @@ import Vue from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import createStore from '~/notes/stores'; import awardsNote from '~/notes/components/note_awards_list.vue'; import { noteableDataMock, notesDataMock } from '../mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('note_awards_list component', () => { let store; diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index b14ec2a65be..1c6603899d3 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -1,4 +1,6 @@ import { mount, createLocalVue } from '@vue/test-utils'; +import mockDiffFile from 'jest/diffs/mock_data/diff_file'; +import { trimText } from 'helpers/text_helper'; import createStore from '~/notes/stores'; import noteableDiscussion from '~/notes/components/noteable_discussion.vue'; import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; @@ -12,8 +14,6 @@ import { loggedOutnoteableData, userDataMock, } from '../mock_data'; -import mockDiffFile from 'jest/diffs/mock_data/diff_file'; -import { trimText } from 'helpers/text_helper'; const discussionWithTwoUnresolvedNotes = 'merge_requests/resolved_diff_discussion.json'; @@ -89,6 +89,23 @@ describe('noteable_discussion component', () => { }); }); + it('should expand discussion', async () => { + const expandDiscussion = jest.fn(); + const discussion = { ...discussionMock }; + discussion.expanded = false; + + wrapper.setProps({ discussion }); + wrapper.setMethods({ expandDiscussion }); + + await wrapper.vm.$nextTick(); + + wrapper.vm.showReplyForm(); + + await wrapper.vm.$nextTick(); + + expect(expandDiscussion).toHaveBeenCalledWith({ discussionId: discussion.id }); + }); + it('does not render jump to thread button', () => { expect(wrapper.find('*[data-original-title="Jump to next unresolved thread"]').exists()).toBe( false, diff --git a/spec/frontend/notes/components/noteable_note_spec.js b/spec/frontend/notes/components/noteable_note_spec.js index fc238feb974..a08e86d92d3 100644 --- a/spec/frontend/notes/components/noteable_note_spec.js +++ b/spec/frontend/notes/components/noteable_note_spec.js @@ -83,18 +83,34 @@ describe('issue_note', () => { }); }); - it('should render multiline comment if editing discussion root', () => { - wrapper.setProps({ discussionRoot: true }); - wrapper.vm.isEditing = true; - - return wrapper.vm.$nextTick().then(() => { - expect(findMultilineComment().exists()).toBe(true); + it('should only render if it has everything it needs', () => { + const position = { + line_range: { + start: { + line_code: 'abc_1_1', + type: null, + old_line: '', + new_line: '', + }, + end: { + line_code: 'abc_2_2', + type: null, + old_line: '2', + new_line: '2', + }, + }, + }; + const line = { + line_code: 'abc_1_1', + type: null, + old_line: '1', + new_line: '1', + }; + wrapper.setProps({ + note: { ...note, position }, + discussionRoot: true, + line, }); - }); - - it('should not render multiline comment form unless it is the discussion root', () => { - wrapper.setProps({ discussionRoot: false }); - wrapper.vm.isEditing = true; return wrapper.vm.$nextTick().then(() => { expect(findMultilineComment().exists()).toBe(false); diff --git a/spec/frontend/notes/mixins/discussion_navigation_spec.js b/spec/frontend/notes/mixins/discussion_navigation_spec.js index ecff95b6fe0..11c0bbfefc9 100644 --- a/spec/frontend/notes/mixins/discussion_navigation_spec.js +++ b/spec/frontend/notes/mixins/discussion_navigation_spec.js @@ -1,11 +1,11 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; import * as utils from '~/lib/utils/common_utils'; import discussionNavigation from '~/notes/mixins/discussion_navigation'; import eventHub from '~/notes/event_hub'; import createEventHub from '~/helpers/event_hub_factory'; import notesModule from '~/notes/stores/modules'; -import { setHTMLFixture } from 'helpers/fixtures'; const discussion = (id, index) => ({ id, @@ -66,6 +66,35 @@ describe('Discussion navigation mixin', () => { const findDiscussion = (selector, id) => document.querySelector(`${selector}[data-discussion-id="${id}"]`); + describe('jumpToFirstUnresolvedDiscussion method', () => { + let vm; + + beforeEach(() => { + createComponent(); + + ({ vm } = wrapper); + + jest.spyOn(store, 'dispatch'); + jest.spyOn(vm, 'jumpToNextDiscussion'); + }); + + it('triggers the setCurrentDiscussionId action with null as the value', () => { + vm.jumpToFirstUnresolvedDiscussion(); + + expect(store.dispatch).toHaveBeenCalledWith('setCurrentDiscussionId', null); + }); + + it('triggers the jumpToNextDiscussion action when the previous store action succeeds', () => { + store.dispatch.mockResolvedValue(); + + vm.jumpToFirstUnresolvedDiscussion(); + + return vm.$nextTick().then(() => { + expect(vm.jumpToNextDiscussion).toHaveBeenCalled(); + }); + }); + }); + describe('cycle through discussions', () => { beforeEach(() => { window.mrTabs = { eventHub: createEventHub(), tabShown: jest.fn() }; diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 909a4a797ae..6b8d0790669 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -1,7 +1,7 @@ import { TEST_HOST } from 'spec/test_constants'; import AxiosMockAdapter from 'axios-mock-adapter'; import Api from '~/api'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; import * as actions from '~/notes/stores/actions'; import * as mutationTypes from '~/notes/stores/mutation_types'; import * as notesConstants from '~/notes/constants'; @@ -19,7 +19,9 @@ import { } from '../mock_data'; import axios from '~/lib/utils/axios_utils'; import * as utils from '~/notes/stores/utils'; -import updateIssueConfidentialMutation from '~/sidebar/components/confidential/queries/update_issue_confidential.mutation.graphql'; +import updateIssueConfidentialMutation from '~/sidebar/components/confidential/mutations/update_issue_confidential.mutation.graphql'; +import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; const TEST_ERROR_MESSAGE = 'Test error message'; jest.mock('~/flash'); @@ -1219,7 +1221,7 @@ describe('Actions Notes Store', () => { }); }); - describe('updateConfidentialityOnIssue', () => { + describe('updateConfidentialityOnIssuable', () => { state = { noteableData: { confidential: false } }; const iid = '1'; const projectPath = 'full/path'; @@ -1234,13 +1236,13 @@ describe('Actions Notes Store', () => { }); it('calls gqClient mutation one time', () => { - actions.updateConfidentialityOnIssue({ commit: () => {}, state, getters }, actionArgs); + actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs); expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); }); it('calls gqClient mutation with the correct values', () => { - actions.updateConfidentialityOnIssue({ commit: () => {}, state, getters }, actionArgs); + actions.updateConfidentialityOnIssuable({ commit: () => {}, state, getters }, actionArgs); expect(utils.gqClient.mutate).toHaveBeenCalledWith({ mutation: updateIssueConfidentialMutation, @@ -1253,7 +1255,7 @@ describe('Actions Notes Store', () => { const commitSpy = jest.fn(); return actions - .updateConfidentialityOnIssue({ commit: commitSpy, state, getters }, actionArgs) + .updateConfidentialityOnIssuable({ commit: commitSpy, state, getters }, actionArgs) .then(() => { expect(commitSpy).toHaveBeenCalledWith( mutationTypes.SET_ISSUE_CONFIDENTIAL, @@ -1263,4 +1265,75 @@ describe('Actions Notes Store', () => { }); }); }); + + describe.each` + issuableType + ${'issue'} | ${'merge_request'} + `('updateLockedAttribute for issuableType=$issuableType', ({ issuableType }) => { + // Payload for mutation query + state = { noteableData: { discussion_locked: false } }; + const targetType = issuableType; + const getters = { getNoteableData: { iid: '1', targetType } }; + + // Target state after mutation + const locked = true; + const actionArgs = { fullPath: 'full/path', locked }; + const input = { iid: '1', projectPath: 'full/path', locked: true }; + + // Helper functions + const targetMutation = () => { + return targetType === 'issue' ? updateIssueLockMutation : updateMergeRequestLockMutation; + }; + + const mockResolvedValue = () => { + return targetType === 'issue' + ? { data: { issueSetLocked: { issue: { discussionLocked: locked } } } } + : { data: { mergeRequestSetLocked: { mergeRequest: { discussionLocked: locked } } } }; + }; + + beforeEach(() => { + jest.spyOn(utils.gqClient, 'mutate').mockResolvedValue(mockResolvedValue()); + }); + + it('calls gqClient mutation one time', () => { + actions.updateLockedAttribute({ commit: () => {}, state, getters }, actionArgs); + + expect(utils.gqClient.mutate).toHaveBeenCalledTimes(1); + }); + + it('calls gqClient mutation with the correct values', () => { + actions.updateLockedAttribute({ commit: () => {}, state, getters }, actionArgs); + + expect(utils.gqClient.mutate).toHaveBeenCalledWith({ + mutation: targetMutation(), + variables: { input }, + }); + }); + + describe('on success of mutation', () => { + it('calls commit with the correct values', () => { + const commitSpy = jest.fn(); + + return actions + .updateLockedAttribute({ commit: commitSpy, state, getters }, actionArgs) + .then(() => { + expect(commitSpy).toHaveBeenCalledWith(mutationTypes.SET_ISSUABLE_LOCK, locked); + }); + }); + }); + }); + + describe('updateDiscussionPosition', () => { + it('update the assignees state', done => { + const updatedPosition = { discussionId: 1, position: { test: true } }; + testAction( + actions.updateDiscussionPosition, + updatedPosition, + { state: { discussions: [] } }, + [{ type: mutationTypes.UPDATE_DISCUSSION_POSITION, payload: updatedPosition }], + [], + done, + ); + }); + }); }); diff --git a/spec/frontend/notes/stores/mutation_spec.js b/spec/frontend/notes/stores/mutation_spec.js index 0ad18ba9b6a..b953bffc4fe 100644 --- a/spec/frontend/notes/stores/mutation_spec.js +++ b/spec/frontend/notes/stores/mutation_spec.js @@ -833,13 +833,27 @@ describe('Notes Store mutations', () => { state = { noteableData: { confidential: false } }; }); - it('sets sort order', () => { + it('should set issuable as confidential', () => { mutations.SET_ISSUE_CONFIDENTIAL(state, true); expect(state.noteableData.confidential).toBe(true); }); }); + describe('SET_ISSUABLE_LOCK', () => { + let state; + + beforeEach(() => { + state = { noteableData: { discussion_locked: false } }; + }); + + it('should set issuable as locked', () => { + mutations.SET_ISSUABLE_LOCK(state, true); + + expect(state.noteableData.discussion_locked).toBe(true); + }); + }); + describe('UPDATE_ASSIGNEES', () => { it('should update assignees', () => { const state = { @@ -851,4 +865,20 @@ describe('Notes Store mutations', () => { expect(state.noteableData.assignees).toEqual([userDataMock.id]); }); }); + + describe('UPDATE_DISCUSSION_POSITION', () => { + it('should upate the discusion position', () => { + const discussion1 = { id: 1, position: { line_code: 'abc_1_1' } }; + const discussion2 = { id: 2, position: { line_code: 'abc_2_2' } }; + const discussion3 = { id: 3, position: { line_code: 'abc_3_3' } }; + const state = { + discussions: [discussion1, discussion2, discussion3], + }; + const discussion1Position = { ...discussion1.position }; + const position = { ...discussion1Position, test: true }; + + mutations.UPDATE_DISCUSSION_POSITION(state, { discussionId: discussion1.id, position }); + expect(state.discussions[0].position).toEqual(position); + }); + }); }); diff --git a/spec/frontend/onboarding_issues/index_spec.js b/spec/frontend/onboarding_issues/index_spec.js index b844caa07aa..d476ba1cf5a 100644 --- a/spec/frontend/onboarding_issues/index_spec.js +++ b/spec/frontend/onboarding_issues/index_spec.js @@ -1,7 +1,7 @@ import $ from 'jquery'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { showLearnGitLabIssuesPopover } from '~/onboarding_issues'; import { getCookie, setCookie, removeCookie } from '~/lib/utils/common_utils'; -import setWindowLocation from 'helpers/set_window_location_helper'; import Tracking from '~/tracking'; describe('Onboarding Issues Popovers', () => { diff --git a/spec/frontend/operation_settings/components/metrics_settings_spec.js b/spec/frontend/operation_settings/components/metrics_settings_spec.js index 398b61ec693..c7ea23f9913 100644 --- a/spec/frontend/operation_settings/components/metrics_settings_spec.js +++ b/spec/frontend/operation_settings/components/metrics_settings_spec.js @@ -1,5 +1,5 @@ import { mount, shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; +import { GlButton, GlLink, GlFormGroup, GlFormInput, GlFormSelect } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import MetricsSettings from '~/operation_settings/components/metrics_settings.vue'; @@ -9,7 +9,7 @@ import { timezones } from '~/monitoring/format_date'; import store from '~/operation_settings/store'; import axios from '~/lib/utils/axios_utils'; import { refreshCurrentPage } from '~/lib/utils/url_utility'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/lib/utils/url_utility'); jest.mock('~/flash'); @@ -56,12 +56,12 @@ describe('operation settings external dashboard component', () => { it('renders header text', () => { mountComponent(); - expect(wrapper.find('.js-section-header').text()).toBe('Metrics Dashboard'); + expect(wrapper.find('.js-section-header').text()).toBe('Metrics dashboard'); }); describe('expand/collapse button', () => { it('renders as an expand button by default', () => { - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); expect(button.text()).toBe('Expand'); }); @@ -160,8 +160,7 @@ describe('operation settings external dashboard component', () => { }); describe('submit button', () => { - const findSubmitButton = () => - wrapper.find('.settings-content form').find(GlDeprecatedButton); + const findSubmitButton = () => wrapper.find('.settings-content form').find(GlButton); const endpointRequest = [ operationsSettingsEndpoint, 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 new file mode 100644 index 00000000000..172b8919673 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/code_instruction_spec.js.snap @@ -0,0 +1,46 @@ +// 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 new file mode 100644 index 00000000000..852292e084b --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/conan_installation_spec.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`ConanInstallation renders all the messages 1`] = ` +<div> + <h3 + class="gl-font-lg" + > + Installation + </h3> + + <h4 + class="gl-font-base" + > + + Conan Command + + </h4> + + <code-instruction-stub + copytext="Copy Conan Command" + instruction="foo/command" + trackingaction="copy_conan_command" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <h4 + class="gl-font-base" + > + + Add Conan Remote + + </h4> + + <code-instruction-stub + copytext="Copy Conan Setup Command" + instruction="foo/setup" + trackingaction="copy_conan_setup_command" + /> + + <gl-sprintf-stub + message="For more information on the Conan registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; 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 new file mode 100644 index 00000000000..28b7ca442eb --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/dependency_row_spec.js.snap @@ -0,0 +1,34 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`DependencyRow renders full dependency 1`] = ` +<div + class="gl-responsive-table-row" +> + <div + class="table-section section-50" + > + <strong + class="gl-text-body" + > + Test.Dependency + </strong> + + <span + data-testid="target-framework" + > + (.NETStandard2.0) + </span> + </div> + + <div + class="table-section section-50 gl-display-flex justify-content-md-end" + data-testid="version-pattern" + > + <span + class="gl-text-body" + > + 2.3.7 + </span> + </div> +</div> +`; diff --git a/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap b/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap new file mode 100644 index 00000000000..a1751d69c70 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/history_element_spec.js.snap @@ -0,0 +1,38 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`History Element renders the correct markup 1`] = ` +<li + class="timeline-entry system-note note-wrapper gl-mb-6!" +> + <div + class="timeline-entry-inner" + > + <div + class="timeline-icon" + > + <gl-icon-stub + name="pencil" + size="16" + /> + </div> + + <div + class="timeline-content" + > + <div + class="note-header" + > + <span> + <div + data-testid="default-slot" + /> + </span> + </div> + + <div + class="note-body" + /> + </div> + </div> +</li> +`; 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 new file mode 100644 index 00000000000..10e54500797 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/maven_installation_spec.js.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MavenInstallation renders all the messages 1`] = ` +<div> + <h3 + class="gl-font-lg" + > + 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." + /> + </p> + + <code-instruction-stub + copytext="Copy Maven XML" + instruction="foo/xml" + multiline="true" + trackingaction="copy_maven_xml" + /> + + <h4 + class="gl-font-base" + > + + Maven Command + + </h4> + + <code-instruction-stub + copytext="Copy Maven command" + instruction="foo/command" + trackingaction="copy_maven_command" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <p> + <gl-sprintf-stub + message="If you haven't already done so, you will need to add the below to your %{codeStart}pom.xml%{codeEnd} file." + /> + </p> + + <code-instruction-stub + copytext="Copy Maven registry XML" + instruction="foo/setup" + multiline="true" + trackingaction="copy_maven_setup_xml" + /> + + <gl-sprintf-stub + message="For more information on the Maven registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; 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 new file mode 100644 index 00000000000..58a509e6847 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/npm_installation_spec.js.snap @@ -0,0 +1,69 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NpmInstallation renders all the messages 1`] = ` +<div> + <h3 + class="gl-font-lg" + > + Installation + </h3> + + <h4 + class="gl-font-base" + > + npm command + </h4> + + <code-instruction-stub + copytext="Copy npm command" + instruction="npm i @Test/package" + trackingaction="copy_npm_install_command" + /> + + <h4 + class="gl-font-base" + > + yarn command + </h4> + + <code-instruction-stub + copytext="Copy yarn command" + instruction="yarn add @Test/package" + trackingaction="copy_yarn_install_command" + /> + + <h3 + class="gl-font-lg" + > + 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" + trackingaction="copy_npm_setup_command" + /> + + <h4 + class="gl-font-base" + > + yarn command + </h4> + + <code-instruction-stub + copytext="Copy yarn setup command" + instruction="echo \\\\\\"@Test:registry\\\\\\" \\\\\\"undefined\\\\\\" >> .yarnrc" + trackingaction="copy_yarn_setup_command" + /> + + <gl-sprintf-stub + message="You may also need to setup authentication using an auth token. %{linkStart}See the documentation%{linkEnd} to find out more." + /> +</div> +`; 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 new file mode 100644 index 00000000000..67810290c62 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/nuget_installation_spec.js.snap @@ -0,0 +1,49 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`NugetInstallation renders all the messages 1`] = ` +<div> + <h3 + class="gl-font-lg" + > + Installation + </h3> + + <h4 + class="gl-font-base" + > + + NuGet Command + + </h4> + + <code-instruction-stub + copytext="Copy NuGet Command" + instruction="foo/command" + trackingaction="copy_nuget_install_command" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <h4 + class="gl-font-base" + > + + Add NuGet Source + + </h4> + + <code-instruction-stub + copytext="Copy NuGet Setup Command" + instruction="foo/setup" + trackingaction="copy_nuget_setup_command" + /> + + <gl-sprintf-stub + message="For more information on the NuGet registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; 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 new file mode 100644 index 00000000000..bdcd4a9e077 --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/package_title_spec.js.snap @@ -0,0 +1,172 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PackageTitle renders with tags 1`] = ` +<div + class="gl-flex-direction-column" +> + <div + class="gl-display-flex" + > + <!----> + + <div + class="gl-display-flex gl-flex-direction-column" + > + <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" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <gl-sprintf-stub + message="v%{version} published %{timeAgo}" + /> + </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" + > + <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" + > + 300 bytes + </span> + </div> + </div> +</div> +`; + +exports[`PackageTitle renders without tags 1`] = ` +<div + class="gl-flex-direction-column" +> + <div + class="gl-display-flex" + > + <!----> + + <div + class="gl-display-flex gl-flex-direction-column" + > + <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" + > + <gl-icon-stub + class="gl-mr-3" + name="eye" + size="16" + /> + + <gl-sprintf-stub + message="v%{version} published %{timeAgo}" + /> + </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" + > + <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" + > + 300 bytes + </span> + </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 new file mode 100644 index 00000000000..5c1e74d73af --- /dev/null +++ b/spec/frontend/packages/details/components/__snapshots__/pypi_installation_spec.js.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`PypiInstallation renders all the messages 1`] = ` +<div> + <h3 + class="gl-font-lg" + > + Installation + </h3> + + <h4 + class="gl-font-base" + > + + Pip Command + + </h4> + + <code-instruction-stub + copytext="Copy Pip command" + data-testid="pip-command" + instruction="pip install" + trackingaction="copy_pip_install_command" + /> + + <h3 + class="gl-font-lg" + > + Registry setup + </h3> + + <p> + <gl-sprintf-stub + message="If you haven't already done so, you will need to add the below to your %{codeStart}.pypirc%{codeEnd} file." + /> + </p> + + <code-instruction-stub + copytext="Copy .pypirc content" + data-testid="pypi-setup-content" + instruction="python setup" + multiline="true" + trackingaction="copy_pypi_setup_command" + /> + + <gl-sprintf-stub + message="For more information on the PyPi registry, %{linkStart}see the documentation%{linkEnd}." + /> +</div> +`; diff --git a/spec/frontend/packages/details/components/additional_metadata_spec.js b/spec/frontend/packages/details/components/additional_metadata_spec.js new file mode 100644 index 00000000000..b2337b86740 --- /dev/null +++ b/spec/frontend/packages/details/components/additional_metadata_spec.js @@ -0,0 +1,119 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import DetailsRow from '~/registry/shared/components/details_row.vue'; +import component from '~/packages/details/components/additional_metadata.vue'; + +import { mavenPackage, conanPackage, nugetPackage, npmPackage } from '../../mock_data'; + +describe('Package Additional Metadata', () => { + let wrapper; + const defaultProps = { + packageEntity: { ...mavenPackage }, + }; + + const mountComponent = props => { + wrapper = shallowMount(component, { + propsData: { ...defaultProps, ...props }, + stubs: { + DetailsRow, + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTitle = () => wrapper.find('[data-testid="title"]'); + const findMainArea = () => wrapper.find('[data-testid="main"]'); + const findNugetSource = () => wrapper.find('[data-testid="nuget-source"]'); + const findNugetLicense = () => wrapper.find('[data-testid="nuget-license"]'); + const findConanRecipe = () => wrapper.find('[data-testid="conan-recipe"]'); + const findMavenApp = () => wrapper.find('[data-testid="maven-app"]'); + const findMavenGroup = () => wrapper.find('[data-testid="maven-group"]'); + const findElementLink = container => container.find(GlLink); + + it('has the correct title', () => { + mountComponent(); + + const title = findTitle(); + + expect(title.exists()).toBe(true); + expect(title.text()).toBe('Additional Metadata'); + }); + + describe.each` + packageEntity | visible | metadata + ${mavenPackage} | ${true} | ${'maven_metadatum'} + ${conanPackage} | ${true} | ${'conan_metadatum'} + ${nugetPackage} | ${true} | ${'nuget_metadatum'} + ${npmPackage} | ${false} | ${null} + `('Component visibility', ({ packageEntity, visible, metadata }) => { + it(`Is ${visible} that the component markup is visible when the package is ${packageEntity.package_type}`, () => { + mountComponent({ packageEntity }); + + expect(findTitle().exists()).toBe(visible); + expect(findMainArea().exists()).toBe(visible); + }); + + it(`The component is hidden if ${metadata} is missing`, () => { + mountComponent({ packageEntity: { ...packageEntity, [metadata]: null } }); + + expect(findTitle().exists()).toBe(false); + expect(findMainArea().exists()).toBe(false); + }); + }); + + describe('nuget metadata', () => { + beforeEach(() => { + mountComponent({ packageEntity: nugetPackage }); + }); + + it.each` + name | finderFunction | text | link | icon + ${'source'} | ${findNugetSource} | ${'Source project located at project-foo-url'} | ${'project_url'} | ${'project'} + ${'license'} | ${findNugetLicense} | ${'License information located at license-foo-url'} | ${'license_url'} | ${'license'} + `('$name element', ({ finderFunction, text, link, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + expect(findElementLink(element).attributes('href')).toBe(nugetPackage.nuget_metadatum[link]); + }); + }); + + describe('conan metadata', () => { + beforeEach(() => { + mountComponent({ packageEntity: conanPackage }); + }); + + it.each` + name | finderFunction | text | icon + ${'recipe'} | ${findConanRecipe} | ${'Recipe: conan-package/1.0.0@conan+conan-package/stable'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); + }); + + describe('maven metadata', () => { + beforeEach(() => { + mountComponent(); + }); + + it.each` + name | finderFunction | text | icon + ${'app'} | ${findMavenApp} | ${'App name: test-app'} | ${'information-o'} + ${'group'} | ${findMavenGroup} | ${'App group: com.test.app'} | ${'information-o'} + `('$name element', ({ finderFunction, text, icon }) => { + const element = finderFunction(); + expect(element.exists()).toBe(true); + expect(element.text()).toBe(text); + expect(element.props('icon')).toBe(icon); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/app_spec.js b/spec/frontend/packages/details/components/app_spec.js new file mode 100644 index 00000000000..f535f3f5744 --- /dev/null +++ b/spec/frontend/packages/details/components/app_spec.js @@ -0,0 +1,281 @@ +import Vuex from 'vuex'; +import { mount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlModal } from '@gitlab/ui'; +import stubChildren from 'helpers/stub_children'; +import Tracking from '~/tracking'; +import * as getters from '~/packages/details/store/getters'; +import PackagesApp from '~/packages/details/components/app.vue'; +import PackageTitle from '~/packages/details/components/package_title.vue'; + +import * as SharedUtils from '~/packages/shared/utils'; +import { TrackingActions } from '~/packages/shared/constants'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackageListRow from '~/packages/shared/components/package_list_row.vue'; + +import DependencyRow from '~/packages/details/components/dependency_row.vue'; +import PackageHistory from '~/packages/details/components/package_history.vue'; +import AdditionalMetadata from '~/packages/details/components/additional_metadata.vue'; +import InstallationCommands from '~/packages/details/components/installation_commands.vue'; + +import { + composerPackage, + conanPackage, + mavenPackage, + mavenFiles, + npmPackage, + npmFiles, + nugetPackage, +} from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackagesApp', () => { + let wrapper; + let store; + const fetchPackageVersions = jest.fn(); + + function createComponent({ + packageEntity = mavenPackage, + packageFiles = mavenFiles, + isLoading = false, + oneColumnView = false, + } = {}) { + store = new Vuex.Store({ + state: { + isLoading, + packageEntity, + packageFiles, + canDelete: true, + destroyPath: 'destroy-package-path', + emptySvgPath: 'empty-illustration', + npmPath: 'foo', + npmHelpPath: 'foo', + projectName: 'bar', + oneColumnView, + }, + actions: { + fetchPackageVersions, + }, + getters, + }); + + wrapper = mount(PackagesApp, { + localVue, + store, + stubs: { + ...stubChildren(PackagesApp), + GlButton: false, + GlModal: false, + GlTab: false, + GlTabs: false, + GlTable: false, + }, + }); + } + + const packageTitle = () => wrapper.find(PackageTitle); + const emptyState = () => wrapper.find(GlEmptyState); + const allFileRows = () => wrapper.findAll('.js-file-row'); + const firstFileDownloadLink = () => wrapper.find('.js-file-download'); + const deleteButton = () => wrapper.find('.js-delete-button'); + const deleteModal = () => wrapper.find(GlModal); + const modalDeleteButton = () => wrapper.find({ ref: 'modal-delete-button' }); + const versionsTab = () => wrapper.find('.js-versions-tab > a'); + const packagesLoader = () => wrapper.find(PackagesListLoader); + const packagesVersionRows = () => wrapper.findAll(PackageListRow); + const noVersionsMessage = () => wrapper.find('[data-testid="no-versions-message"]'); + const dependenciesTab = () => wrapper.find('.js-dependencies-tab > a'); + const dependenciesCountBadge = () => wrapper.find('[data-testid="dependencies-badge"]'); + const noDependenciesMessage = () => wrapper.find('[data-testid="no-dependencies-message"]'); + const dependencyRows = () => wrapper.findAll(DependencyRow); + const findPackageHistory = () => wrapper.find(PackageHistory); + const findAdditionalMetadata = () => wrapper.find(AdditionalMetadata); + const findInstallationCommands = () => wrapper.find(InstallationCommands); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the app and displays the package title', () => { + createComponent(); + + expect(packageTitle()).toExist(); + }); + + it('renders an empty state component when no an invalid package is passed as a prop', () => { + createComponent({ + packageEntity: {}, + }); + + expect(emptyState()).toExist(); + }); + + it('package history has the right props', () => { + createComponent(); + expect(findPackageHistory().exists()).toBe(true); + expect(findPackageHistory().props('packageEntity')).toEqual(wrapper.vm.packageEntity); + expect(findPackageHistory().props('projectName')).toEqual(wrapper.vm.projectName); + }); + + it('additional metadata has the right props', () => { + createComponent(); + expect(findAdditionalMetadata().exists()).toBe(true); + expect(findAdditionalMetadata().props('packageEntity')).toEqual(wrapper.vm.packageEntity); + }); + + it('installation commands has the right props', () => { + createComponent(); + expect(findInstallationCommands().exists()).toBe(true); + expect(findInstallationCommands().props('packageEntity')).toEqual(wrapper.vm.packageEntity); + }); + + it('hides the files table if package type is COMPOSER', () => { + createComponent({ packageEntity: composerPackage }); + expect(allFileRows().exists()).toBe(false); + }); + + it('renders a single file for an npm package as they only contain one file', () => { + createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); + + expect(allFileRows()).toExist(); + expect(allFileRows()).toHaveLength(1); + }); + + it('renders multiple files for a package that contains more than one file', () => { + createComponent(); + + expect(allFileRows()).toExist(); + expect(allFileRows()).toHaveLength(2); + }); + + it('allows the user to download a package file by rendering a download link', () => { + createComponent(); + + expect(allFileRows()).toExist(); + expect(firstFileDownloadLink().vm.$attrs.href).toContain('download'); + }); + + describe('deleting packages', () => { + beforeEach(() => { + createComponent(); + deleteButton().trigger('click'); + }); + + it('shows the delete confirmation modal when delete is clicked', () => { + expect(deleteModal()).toExist(); + }); + }); + + describe('versions', () => { + describe('api call', () => { + beforeEach(() => { + createComponent(); + }); + + it('makes api request on first click of tab', () => { + versionsTab().trigger('click'); + + expect(fetchPackageVersions).toHaveBeenCalled(); + }); + }); + + it('displays the loader when state is loading', () => { + createComponent({ isLoading: true }); + + expect(packagesLoader().exists()).toBe(true); + }); + + it('displays the correct version count when the package has versions', () => { + createComponent({ packageEntity: npmPackage }); + + expect(packagesVersionRows()).toHaveLength(npmPackage.versions.length); + }); + + it('displays the no versions message when there are none', () => { + createComponent(); + + expect(noVersionsMessage().exists()).toBe(true); + }); + }); + + describe('dependency links', () => { + it('does not show the dependency links for a non nuget package', () => { + createComponent(); + + expect(dependenciesTab().exists()).toBe(false); + }); + + it('shows the dependencies tab with 0 count when a nuget package with no dependencies', () => { + createComponent({ + packageEntity: { + ...nugetPackage, + dependency_links: [], + }, + }); + + return wrapper.vm.$nextTick(() => { + const dependenciesBadge = dependenciesCountBadge(); + + expect(dependenciesTab().exists()).toBe(true); + expect(dependenciesBadge.exists()).toBe(true); + expect(dependenciesBadge.text()).toBe('0'); + expect(noDependenciesMessage().exists()).toBe(true); + }); + }); + + it('renders the correct number of dependency rows for a nuget package', () => { + createComponent({ packageEntity: nugetPackage }); + + return wrapper.vm.$nextTick(() => { + const dependenciesBadge = dependenciesCountBadge(); + + expect(dependenciesTab().exists()).toBe(true); + expect(dependenciesBadge.exists()).toBe(true); + expect(dependenciesBadge.text()).toBe(nugetPackage.dependency_links.length.toString()); + expect(dependencyRows()).toHaveLength(nugetPackage.dependency_links.length); + }); + }); + }); + + 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}`, () => { + createComponent({ packageEntity: conanPackage }); + deleteButton().trigger('click'); + return wrapper.vm.$nextTick().then(() => { + modalDeleteButton().trigger('click'); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); + + 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), + ); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/code_instruction_spec.js b/spec/frontend/packages/details/components/code_instruction_spec.js new file mode 100644 index 00000000000..724eddb9070 --- /dev/null +++ b/spec/frontend/packages/details/components/code_instruction_spec.js @@ -0,0 +1,110 @@ +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'; + +describe('Package code instruction', () => { + let wrapper; + + const defaultProps = { + instruction: 'npm i @my-package', + copyText: 'Copy npm install command', + }; + + function createComponent(props = {}) { + wrapper = mount(CodeInstruction, { + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + const findInstructionInput = () => wrapper.find('.js-instruction-input'); + const findInstructionPre = () => wrapper.find('.js-instruction-pre'); + const findInstructionButton = () => wrapper.find('.js-instruction-button'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('single line', () => { + beforeEach(() => createComponent()); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('multiline', () => { + beforeEach(() => + createComponent({ + instruction: 'this is some\nmultiline text', + copyText: 'Copy the command', + multiline: true, + }), + ); + + it('to match the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('tracking', () => { + let eventSpy; + const trackingAction = 'test_action'; + const label = TrackingLabels.CODE_INSTRUCTION; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + }); + + it('should not track when no trackingAction is provided', () => { + createComponent(); + findInstructionButton().trigger('click'); + + expect(eventSpy).toHaveBeenCalledTimes(0); + }); + + describe('when trackingAction is provided for single line', () => { + beforeEach(() => + createComponent({ + trackingAction, + }), + ); + + it('should track when copying from the input', () => { + findInstructionInput().trigger('copy'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label, + }); + }); + + it('should track when the copy button is pressed', () => { + findInstructionButton().trigger('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label, + }); + }); + }); + + describe('when trackingAction is provided for multiline', () => { + beforeEach(() => + createComponent({ + trackingAction, + multiline: true, + }), + ); + + it('should track when copying from the multiline pre element', () => { + findInstructionPre().trigger('copy'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label, + }); + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/composer_installation_spec.js b/spec/frontend/packages/details/components/composer_installation_spec.js new file mode 100644 index 00000000000..7679d721391 --- /dev/null +++ b/spec/frontend/packages/details/components/composer_installation_spec.js @@ -0,0 +1,95 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +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(); +localVue.use(Vuex); + +describe('ComposerInstallation', () => { + let wrapper; + + const composerRegistryIncludeStr = 'foo/registry'; + const composerPackageIncludeStr = 'foo/package'; + + const store = new Vuex.Store({ + state: { + packageEntity, + composerHelpPath, + }, + getters: { + composerRegistryInclude: () => composerRegistryIncludeStr, + composerPackageInclude: () => composerPackageIncludeStr, + }, + }); + + const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + const findRegistryIncludeTitle = () => wrapper.find('[data-testid="registry-include-title"]'); + const findPackageIncludeTitle = () => wrapper.find('[data-testid="package-include-title"]'); + const findHelpText = () => wrapper.find('[data-testid="help-text"]'); + const findHelpLink = () => wrapper.find(GlLink); + + function createComponent() { + wrapper = shallowMount(ComposerInstallation, { + localVue, + store, + stubs: { + GlSprintf, + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('registry include command', () => { + it('uses code_instructions', () => { + const registryIncludeCommand = findCodeInstructions().at(0); + expect(registryIncludeCommand.exists()).toBe(true); + expect(registryIncludeCommand.props()).toMatchObject({ + instruction: composerRegistryIncludeStr, + copyText: 'Copy registry include', + trackingAction: TrackingActions.COPY_COMPOSER_REGISTRY_INCLUDE_COMMAND, + }); + }); + + it('has the correct title', () => { + expect(findRegistryIncludeTitle().text()).toBe('composer.json registry include'); + }); + }); + + describe('package include command', () => { + it('uses code_instructions', () => { + const registryIncludeCommand = findCodeInstructions().at(1); + expect(registryIncludeCommand.exists()).toBe(true); + expect(registryIncludeCommand.props()).toMatchObject({ + instruction: composerPackageIncludeStr, + copyText: 'Copy require package include', + trackingAction: TrackingActions.COPY_COMPOSER_PACKAGE_INCLUDE_COMMAND, + }); + }); + + it('has the correct title', () => { + expect(findPackageIncludeTitle().text()).toBe('composer.json require package include'); + }); + + it('has the correct help text', () => { + expect(findHelpText().text()).toBe( + 'For more information on Composer packages in GitLab, see the documentation.', + ); + expect(findHelpLink().attributes()).toMatchObject({ + href: composerHelpPath, + target: '_blank', + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/conan_installation_spec.js b/spec/frontend/packages/details/components/conan_installation_spec.js new file mode 100644 index 00000000000..5b31e38dad5 --- /dev/null +++ b/spec/frontend/packages/details/components/conan_installation_spec.js @@ -0,0 +1,68 @@ +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 { conanPackage as packageEntity } from '../../mock_data'; +import { registryUrl as conanPath } from '../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('ConanInstallation', () => { + let wrapper; + + const conanInstallationCommandStr = 'foo/command'; + const conanSetupCommandStr = 'foo/setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + conanPath, + }, + getters: { + conanInstallationCommand: () => conanInstallationCommandStr, + conanSetupCommand: () => conanSetupCommandStr, + }, + }); + + const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + + function createComponent() { + wrapper = shallowMount(ConanInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect( + findCodeInstructions() + .at(0) + .props('instruction'), + ).toBe(conanInstallationCommandStr); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect( + findCodeInstructions() + .at(1) + .props('instruction'), + ).toBe(conanSetupCommandStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/dependency_row_spec.js b/spec/frontend/packages/details/components/dependency_row_spec.js new file mode 100644 index 00000000000..7d3ee92908d --- /dev/null +++ b/spec/frontend/packages/details/components/dependency_row_spec.js @@ -0,0 +1,62 @@ +import { shallowMount } from '@vue/test-utils'; +import DependencyRow from '~/packages/details/components/dependency_row.vue'; +import { dependencyLinks } from '../../mock_data'; + +describe('DependencyRow', () => { + let wrapper; + + const { withoutFramework, withoutVersion, fullLink } = dependencyLinks; + + function createComponent({ dependencyLink = fullLink } = {}) { + wrapper = shallowMount(DependencyRow, { + propsData: { + dependency: dependencyLink, + }, + }); + } + + const dependencyVersion = () => wrapper.find('[data-testid="version-pattern"]'); + const dependencyFramework = () => wrapper.find('[data-testid="target-framework"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('renders', () => { + it('full dependency', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('version', () => { + it('does not render any version information when not supplied', () => { + createComponent({ dependencyLink: withoutVersion }); + + expect(dependencyVersion().exists()).toBe(false); + }); + + it('does render version info when it exists', () => { + createComponent(); + + expect(dependencyVersion().exists()).toBe(true); + expect(dependencyVersion().text()).toBe(fullLink.version_pattern); + }); + }); + + describe('target framework', () => { + it('does not render any framework information when not supplied', () => { + createComponent({ dependencyLink: withoutFramework }); + + expect(dependencyFramework().exists()).toBe(false); + }); + + it('does render framework info when it exists', () => { + createComponent(); + + expect(dependencyFramework().exists()).toBe(true); + expect(dependencyFramework().text()).toBe(`(${fullLink.target_framework})`); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/history_element_spec.js b/spec/frontend/packages/details/components/history_element_spec.js new file mode 100644 index 00000000000..e8746fc93f5 --- /dev/null +++ b/spec/frontend/packages/details/components/history_element_spec.js @@ -0,0 +1,57 @@ +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'; + +describe('History Element', () => { + let wrapper; + const defaultProps = { + icon: 'pencil', + }; + + const mountComponent = () => { + wrapper = shallowMount(component, { + propsData: { ...defaultProps }, + stubs: { + TimelineEntryItem, + }, + slots: { + default: '<div data-testid="default-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTimelineEntry = () => wrapper.find(TimelineEntryItem); + const findGlIcon = () => wrapper.find(GlIcon); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + it('renders the correct markup', () => { + mountComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has a default slot', () => { + mountComponent(); + + expect(findDefaultSlot().exists()).toBe(true); + }); + it('has a timeline entry', () => { + mountComponent(); + + expect(findTimelineEntry().exists()).toBe(true); + }); + it('has an icon', () => { + mountComponent(); + + const icon = findGlIcon(); + + expect(icon.exists()).toBe(true); + expect(icon.attributes('name')).toBe(defaultProps.icon); + }); +}); diff --git a/spec/frontend/packages/details/components/installations_commands_spec.js b/spec/frontend/packages/details/components/installations_commands_spec.js new file mode 100644 index 00000000000..60da34ebcd9 --- /dev/null +++ b/spec/frontend/packages/details/components/installations_commands_spec.js @@ -0,0 +1,57 @@ +import { shallowMount } from '@vue/test-utils'; +import InstallationCommands from '~/packages/details/components/installation_commands.vue'; + +import NpmInstallation from '~/packages/details/components/npm_installation.vue'; +import MavenInstallation from '~/packages/details/components/maven_installation.vue'; +import ConanInstallation from '~/packages/details/components/conan_installation.vue'; +import NugetInstallation from '~/packages/details/components/nuget_installation.vue'; +import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; +import ComposerInstallation from '~/packages/details/components/composer_installation.vue'; + +import { + conanPackage, + mavenPackage, + npmPackage, + nugetPackage, + pypiPackage, + composerPackage, +} from '../../mock_data'; + +describe('InstallationCommands', () => { + let wrapper; + + function createComponent(propsData) { + wrapper = shallowMount(InstallationCommands, { + propsData, + }); + } + + const npmInstallation = () => wrapper.find(NpmInstallation); + const mavenInstallation = () => wrapper.find(MavenInstallation); + const conanInstallation = () => wrapper.find(ConanInstallation); + const nugetInstallation = () => wrapper.find(NugetInstallation); + const pypiInstallation = () => wrapper.find(PypiInstallation); + const composerInstallation = () => wrapper.find(ComposerInstallation); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('installation instructions', () => { + describe.each` + packageEntity | selector + ${conanPackage} | ${conanInstallation} + ${mavenPackage} | ${mavenInstallation} + ${npmPackage} | ${npmInstallation} + ${nugetPackage} | ${nugetInstallation} + ${pypiPackage} | ${pypiInstallation} + ${composerPackage} | ${composerInstallation} + `('renders', ({ packageEntity, selector }) => { + it(`${packageEntity.package_type} instructions exist`, () => { + createComponent({ packageEntity }); + + expect(selector()).toExist(); + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/maven_installation_spec.js b/spec/frontend/packages/details/components/maven_installation_spec.js new file mode 100644 index 00000000000..5d0007294b6 --- /dev/null +++ b/spec/frontend/packages/details/components/maven_installation_spec.js @@ -0,0 +1,91 @@ +import Vuex from 'vuex'; +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 { TrackingActions } from '~/packages/details/constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('MavenInstallation', () => { + let wrapper; + + const xmlCodeBlock = 'foo/xml'; + const mavenCommandStr = 'foo/command'; + const mavenSetupXml = 'foo/setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + mavenPath, + }, + getters: { + mavenInstallationXml: () => xmlCodeBlock, + mavenInstallationCommand: () => mavenCommandStr, + mavenSetupXml: () => mavenSetupXml, + }, + }); + + const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + + function createComponent() { + wrapper = shallowMount(MavenInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct xml block', () => { + expect( + findCodeInstructions() + .at(0) + .props(), + ).toMatchObject({ + instruction: xmlCodeBlock, + multiline: true, + trackingAction: TrackingActions.COPY_MAVEN_XML, + }); + }); + + it('renders the correct maven command', () => { + expect( + findCodeInstructions() + .at(1) + .props(), + ).toMatchObject({ + instruction: mavenCommandStr, + multiline: false, + trackingAction: TrackingActions.COPY_MAVEN_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct xml block', () => { + expect( + findCodeInstructions() + .at(2) + .props(), + ).toMatchObject({ + instruction: mavenSetupXml, + multiline: true, + trackingAction: TrackingActions.COPY_MAVEN_SETUP, + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/npm_installation_spec.js b/spec/frontend/packages/details/components/npm_installation_spec.js new file mode 100644 index 00000000000..f47bac57a66 --- /dev/null +++ b/spec/frontend/packages/details/components/npm_installation_spec.js @@ -0,0 +1,99 @@ +import Vuex from 'vuex'; +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 { TrackingActions } from '~/packages/details/constants'; +import { npmInstallationCommand, npmSetupCommand } from '~/packages/details/store/getters'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('NpmInstallation', () => { + let wrapper; + + const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + + function createComponent() { + const store = new Vuex.Store({ + state: { + packageEntity, + nugetPath, + }, + getters: { + npmInstallationCommand, + npmSetupCommand, + }, + }); + + wrapper = shallowMount(NpmInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct npm command', () => { + expect( + findCodeInstructions() + .at(0) + .props(), + ).toMatchObject({ + instruction: 'npm i @Test/package', + multiline: false, + trackingAction: TrackingActions.COPY_NPM_INSTALL_COMMAND, + }); + }); + + it('renders the correct yarn command', () => { + expect( + findCodeInstructions() + .at(1) + .props(), + ).toMatchObject({ + instruction: 'yarn add @Test/package', + multiline: false, + trackingAction: TrackingActions.COPY_YARN_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct npm command', () => { + expect( + findCodeInstructions() + .at(2) + .props(), + ).toMatchObject({ + instruction: 'echo @Test:registry=undefined >> .npmrc', + multiline: false, + trackingAction: TrackingActions.COPY_NPM_SETUP_COMMAND, + }); + }); + + it('renders the correct yarn command', () => { + expect( + findCodeInstructions() + .at(3) + .props(), + ).toMatchObject({ + 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 new file mode 100644 index 00000000000..a23bf9a18a1 --- /dev/null +++ b/spec/frontend/packages/details/components/nuget_installation_spec.js @@ -0,0 +1,75 @@ +import Vuex from 'vuex'; +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 { TrackingActions } from '~/packages/details/constants'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('NugetInstallation', () => { + let wrapper; + + const nugetInstallationCommandStr = 'foo/command'; + const nugetSetupCommandStr = 'foo/setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + nugetPath, + }, + getters: { + nugetInstallationCommand: () => nugetInstallationCommandStr, + nugetSetupCommand: () => nugetSetupCommandStr, + }, + }); + + const findCodeInstructions = () => wrapper.findAll(CodeInstructions); + + function createComponent() { + wrapper = shallowMount(NugetInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct command', () => { + expect( + findCodeInstructions() + .at(0) + .props(), + ).toMatchObject({ + instruction: nugetInstallationCommandStr, + trackingAction: TrackingActions.COPY_NUGET_INSTALL_COMMAND, + }); + }); + }); + + describe('setup commands', () => { + it('renders the correct command', () => { + expect( + findCodeInstructions() + .at(1) + .props(), + ).toMatchObject({ + instruction: nugetSetupCommandStr, + trackingAction: TrackingActions.COPY_NUGET_SETUP_COMMAND, + }); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/package_history_spec.js b/spec/frontend/packages/details/components/package_history_spec.js new file mode 100644 index 00000000000..e293e119585 --- /dev/null +++ b/spec/frontend/packages/details/components/package_history_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlLink, GlSprintf } from '@gitlab/ui'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import component from '~/packages/details/components/package_history.vue'; + +import { mavenPackage, mockPipelineInfo } from '../../mock_data'; + +describe('Package History', () => { + let wrapper; + const defaultProps = { + projectName: 'baz project', + packageEntity: { ...mavenPackage }, + }; + + const mountComponent = props => { + wrapper = shallowMount(component, { + propsData: { ...defaultProps, ...props }, + stubs: { + HistoryElement: '<div data-testid="history-element"><slot></slot></div>', + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findHistoryElement = testId => wrapper.find(`[data-testid="${testId}"]`); + const findElementLink = container => container.find(GlLink); + const findElementTimeAgo = container => container.find(TimeAgoTooltip); + const findTitle = () => wrapper.find('[data-testid="title"]'); + const findTimeline = () => wrapper.find('[data-testid="timeline"]'); + + it('has the correct title', () => { + mountComponent(); + + const title = findTitle(); + + expect(title.exists()).toBe(true); + expect(title.text()).toBe('History'); + }); + + it('has a timeline container', () => { + mountComponent(); + + const title = findTimeline(); + + expect(title.exists()).toBe(true); + expect(title.classes()).toEqual( + expect.arrayContaining(['timeline', 'main-notes-list', 'notes']), + ); + }); + + describe.each` + name | icon | text | timeAgoTooltip | link + ${'created-on'} | ${'clock'} | ${'Test package version 1.0.0 was created'} | ${mavenPackage.created_at} | ${null} + ${'updated-at'} | ${'pencil'} | ${'Test package version 1.0.0 was updated'} | ${mavenPackage.updated_at} | ${null} + ${'commit'} | ${'commit'} | ${'Commit sha-baz on branch branch-name'} | ${null} | ${mockPipelineInfo.project.commit_url} + ${'pipeline'} | ${'pipeline'} | ${'Pipeline #1 triggered by foo'} | ${mockPipelineInfo.created_at} | ${mockPipelineInfo.project.pipeline_url} + ${'published'} | ${'package'} | ${'Published to the baz project Package Registry'} | ${mavenPackage.created_at} | ${null} + `('history element $name', ({ name, icon, text, timeAgoTooltip, link }) => { + let element; + + beforeEach(() => { + mountComponent({ packageEntity: { ...mavenPackage, pipeline: mockPipelineInfo } }); + element = findHistoryElement(name); + }); + + it('has the correct icon', () => { + expect(element.props('icon')).toBe(icon); + }); + + it('has the correct text', () => { + expect(element.text()).toBe(text); + }); + + it('time-ago tooltip', () => { + const timeAgo = findElementTimeAgo(element); + const exist = Boolean(timeAgoTooltip); + + expect(timeAgo.exists()).toBe(exist); + if (exist) { + expect(timeAgo.props('time')).toBe(timeAgoTooltip); + } + }); + + it('link', () => { + const linkElement = findElementLink(element); + const exist = Boolean(link); + + expect(linkElement.exists()).toBe(exist); + if (exist) { + expect(linkElement.attributes('href')).toBe(link); + } + }); + }); + + describe('when pipelineInfo is missing', () => { + it.each(['commit', 'pipeline'])('%s history element is hidden', name => { + mountComponent(); + expect(findHistoryElement(name).exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/package_title_spec.js b/spec/frontend/packages/details/components/package_title_spec.js new file mode 100644 index 00000000000..a30dc4b8aba --- /dev/null +++ b/spec/frontend/packages/details/components/package_title_spec.js @@ -0,0 +1,168 @@ +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 { + conanPackage, + mavenFiles, + mavenPackage, + mockTags, + npmFiles, + npmPackage, + nugetPackage, +} from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PackageTitle', () => { + let wrapper; + let store; + + function createComponent({ + packageEntity = mavenPackage, + packageFiles = mavenFiles, + icon = null, + } = {}) { + store = new Vuex.Store({ + state: { + packageEntity, + packageFiles, + }, + getters: { + packageTypeDisplay: ({ packageEntity: { package_type: type } }) => type, + packagePipeline: ({ packageEntity: { pipeline = null } }) => pipeline, + packageIcon: () => icon, + }, + }); + + wrapper = shallowMount(PackageTitle, { + localVue, + store, + }); + } + + const packageIcon = () => wrapper.find('[data-testid="package-icon"]'); + const packageType = () => wrapper.find('[data-testid="package-type"]'); + const packageSize = () => wrapper.find('[data-testid="package-size"]'); + const pipelineProject = () => wrapper.find('[data-testid="pipeline-project"]'); + const packageRef = () => wrapper.find('[data-testid="package-ref"]'); + const packageTags = () => wrapper.find(PackageTags); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('renders', () => { + it('without tags', () => { + createComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('with tags', () => { + 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 }); + + expect(packageIcon().exists()).toBe(true); + }); + + it('has the correct src attribute', () => { + createComponent({ icon: fakeSrc }); + + expect(packageIcon().props('src')).toBe(fakeSrc); + }); + + it('does not show an icon when not provided one', () => { + createComponent(); + + expect(packageIcon().exists()).toBe(false); + }); + }); + + describe.each` + packageEntity | expectedResult + ${conanPackage} | ${'conan'} + ${mavenPackage} | ${'maven'} + ${npmPackage} | ${'npm'} + ${nugetPackage} | ${'nuget'} + `(`package type`, ({ packageEntity, expectedResult }) => { + beforeEach(() => createComponent({ packageEntity })); + + it(`${packageEntity.package_type} should render from Vuex getters ${expectedResult}`, () => { + expect(packageType().text()).toBe(expectedResult); + }); + }); + + describe('calculates the package size', () => { + it('correctly calulates when there is only 1 file', () => { + createComponent({ packageEntity: npmPackage, packageFiles: npmFiles }); + + expect(packageSize().text()).toBe('200 bytes'); + }); + + it('correctly calulates when there are multiple files', () => { + createComponent(); + + expect(packageSize().text()).toBe('300 bytes'); + }); + }); + + describe('package tags', () => { + it('displays the package-tags component when the package has tags', () => { + createComponent({ + packageEntity: { + ...npmPackage, + tags: mockTags, + }, + }); + + expect(packageTags().exists()).toBe(true); + }); + + it('does not display the package-tags component when there are no tags', () => { + createComponent(); + + expect(packageTags().exists()).toBe(false); + }); + }); + + describe('package ref', () => { + it('does not display the ref if missing', () => { + 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); + }); + }); + + describe('pipeline project', () => { + it('does not display the project if missing', () => { + createComponent(); + + expect(pipelineProject().exists()).toBe(false); + }); + + it('correctly shows the pipeline project if there is one', () => { + createComponent({ packageEntity: npmPackage }); + + expect(pipelineProject().text()).toBe(npmPackage.pipeline.project.name); + expect(pipelineProject().attributes('href')).toBe(npmPackage.pipeline.project.web_url); + }); + }); +}); diff --git a/spec/frontend/packages/details/components/pypi_installation_spec.js b/spec/frontend/packages/details/components/pypi_installation_spec.js new file mode 100644 index 00000000000..da30b4ba565 --- /dev/null +++ b/spec/frontend/packages/details/components/pypi_installation_spec.js @@ -0,0 +1,60 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { pypiPackage as packageEntity } from 'jest/packages/mock_data'; +import PypiInstallation from '~/packages/details/components/pypi_installation.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('PypiInstallation', () => { + let wrapper; + + const pipCommandStr = 'pip install'; + const pypiSetupStr = 'python setup'; + + const store = new Vuex.Store({ + state: { + packageEntity, + pypiHelpPath: 'foo', + }, + getters: { + pypiPipCommand: () => pipCommandStr, + pypiSetupCommand: () => pypiSetupStr, + }, + }); + + const pipCommand = () => wrapper.find('[data-testid="pip-command"]'); + const setupInstruction = () => wrapper.find('[data-testid="pypi-setup-content"]'); + + function createComponent() { + wrapper = shallowMount(PypiInstallation, { + localVue, + store, + }); + } + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders all the messages', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('installation commands', () => { + it('renders the correct pip command', () => { + expect(pipCommand().props('instruction')).toBe(pipCommandStr); + }); + }); + + describe('setup commands', () => { + it('renders the correct setup block', () => { + expect(setupInstruction().props('instruction')).toBe(pypiSetupStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/mock_data.js b/spec/frontend/packages/details/mock_data.js new file mode 100644 index 00000000000..d43abcedb2e --- /dev/null +++ b/spec/frontend/packages/details/mock_data.js @@ -0,0 +1,47 @@ +export const registryUrl = 'foo/registry'; + +export const mavenMetadata = { + app_group: 'com.test.package.app', + app_name: 'test-package-app', + app_version: '1.0.0', +}; + +export const generateMavenCommand = ({ + app_group: appGroup = '', + app_name: appName = '', + app_version: appVersion = '', +}) => `mvn dependency:get -Dartifact=${appGroup}:${appName}:${appVersion}`; + +export const generateXmlCodeBlock = ({ + app_group: appGroup = '', + app_name: appName = '', + app_version: appVersion = '', +}) => `<dependency> + <groupId>${appGroup}</groupId> + <artifactId>${appName}</artifactId> + <version>${appVersion}</version> +</dependency>`; + +export const generateMavenSetupXml = () => `<repositories> + <repository> + <id>gitlab-maven</id> + <url>${registryUrl}</url> + </repository> +</repositories> + +<distributionManagement> + <repository> + <id>gitlab-maven</id> + <url>${registryUrl}</url> + </repository> + + <snapshotRepository> + <id>gitlab-maven</id> + <url>${registryUrl}</url> + </snapshotRepository> +</distributionManagement>`; + +export const pypiSetupCommandStr = `[gitlab] +repository = foo +username = __token__ +password = <your personal access token>`; diff --git a/spec/frontend/packages/details/store/actions_spec.js b/spec/frontend/packages/details/store/actions_spec.js new file mode 100644 index 00000000000..6dfb2b63f85 --- /dev/null +++ b/spec/frontend/packages/details/store/actions_spec.js @@ -0,0 +1,76 @@ +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 * as types from '~/packages/details/store/mutation_types'; +import { FETCH_PACKAGE_VERSIONS_ERROR } from '~/packages/details/constants'; +import { npmPackage as packageEntity } from '../../mock_data'; + +jest.mock('~/flash.js'); +jest.mock('~/api.js'); + +describe('Actions Package details store', () => { + describe('fetchPackageVersions', () => { + it('should fetch the package versions', done => { + Api.projectPackage = jest.fn().mockResolvedValue({ data: packageEntity }); + + testAction( + fetchPackageVersions, + undefined, + { packageEntity }, + [ + { type: types.SET_LOADING, payload: true }, + { type: types.SET_PACKAGE_VERSIONS, payload: packageEntity.versions }, + { type: types.SET_LOADING, payload: false }, + ], + [], + () => { + expect(Api.projectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + done(); + }, + ); + }); + + it("does not set the versions if they don't exist", done => { + Api.projectPackage = jest.fn().mockResolvedValue({ data: { packageEntity, versions: null } }); + + testAction( + fetchPackageVersions, + undefined, + { packageEntity }, + [{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }], + [], + () => { + expect(Api.projectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + done(); + }, + ); + }); + + it('should create flash on API error', done => { + Api.projectPackage = jest.fn().mockRejectedValue(); + + testAction( + fetchPackageVersions, + undefined, + { packageEntity }, + [{ type: types.SET_LOADING, payload: true }, { type: types.SET_LOADING, payload: false }], + [], + () => { + expect(Api.projectPackage).toHaveBeenCalledWith( + packageEntity.project_id, + packageEntity.id, + ); + expect(createFlash).toHaveBeenCalledWith(FETCH_PACKAGE_VERSIONS_ERROR); + done(); + }, + ); + }); + }); +}); diff --git a/spec/frontend/packages/details/store/getters_spec.js b/spec/frontend/packages/details/store/getters_spec.js new file mode 100644 index 00000000000..307976d4124 --- /dev/null +++ b/spec/frontend/packages/details/store/getters_spec.js @@ -0,0 +1,237 @@ +import { + conanInstallationCommand, + conanSetupCommand, + packagePipeline, + packageTypeDisplay, + packageIcon, + mavenInstallationXml, + mavenInstallationCommand, + mavenSetupXml, + npmInstallationCommand, + npmSetupCommand, + nugetInstallationCommand, + nugetSetupCommand, + pypiPipCommand, + pypiSetupCommand, + composerRegistryInclude, + composerPackageInclude, +} from '~/packages/details/store/getters'; +import { + conanPackage, + npmPackage, + nugetPackage, + mockPipelineInfo, + mavenPackage as packageWithoutBuildInfo, + pypiPackage, +} from '../../mock_data'; +import { + generateMavenCommand, + generateXmlCodeBlock, + generateMavenSetupXml, + registryUrl, + pypiSetupCommandStr, +} from '../mock_data'; +import { generateConanRecipe } from '~/packages/details/utils'; +import { NpmManager } from '~/packages/details/constants'; + +describe('Getters PackageDetails Store', () => { + let state; + + const defaultState = { + packageEntity: packageWithoutBuildInfo, + conanPath: registryUrl, + mavenPath: registryUrl, + npmPath: registryUrl, + nugetPath: registryUrl, + pypiPath: registryUrl, + }; + + const setupState = (testState = {}) => { + state = { + ...defaultState, + ...testState, + }; + }; + + const recipe = generateConanRecipe(conanPackage); + const conanInstallationCommandStr = `conan install ${recipe} --remote=gitlab`; + const conanSetupCommandStr = `conan remote add gitlab ${registryUrl}`; + + const mavenCommandStr = generateMavenCommand(packageWithoutBuildInfo.maven_metadatum); + const mavenInstallationXmlBlock = generateXmlCodeBlock(packageWithoutBuildInfo.maven_metadatum); + const mavenSetupXmlBlock = generateMavenSetupXml(); + + const npmInstallStr = `npm i ${npmPackage.name}`; + const npmSetupStr = `echo @Test:registry=${registryUrl} >> .npmrc`; + const yarnInstallStr = `yarn add ${npmPackage.name}`; + 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>`; + + const pypiPipCommandStr = `pip install ${pypiPackage.name} --index-url ${registryUrl}`; + const composerRegistryIncludeStr = '{"type":"composer","url":"foo"}'; + const composerPackageIncludeStr = JSON.stringify({ + [packageWithoutBuildInfo.name]: packageWithoutBuildInfo.version, + }); + + describe('packagePipeline', () => { + it('should return the pipeline info when pipeline exists', () => { + setupState({ + packageEntity: { + ...npmPackage, + pipeline: mockPipelineInfo, + }, + }); + + expect(packagePipeline(state)).toEqual(mockPipelineInfo); + }); + + it('should return null when build_info does not exist', () => { + setupState(); + + expect(packagePipeline(state)).toBe(null); + }); + }); + + describe('packageTypeDisplay', () => { + describe.each` + packageEntity | expectedResult + ${conanPackage} | ${'Conan'} + ${packageWithoutBuildInfo} | ${'Maven'} + ${npmPackage} | ${'NPM'} + ${nugetPackage} | ${'NuGet'} + ${pypiPackage} | ${'PyPi'} + `(`package type`, ({ packageEntity, expectedResult }) => { + beforeEach(() => setupState({ packageEntity })); + + it(`${packageEntity.package_type} should show as ${expectedResult}`, () => { + expect(packageTypeDisplay(state)).toBe(expectedResult); + }); + }); + }); + + describe('packageIcon', () => { + describe('nuget packages', () => { + it('should return nuget package icon', () => { + setupState({ packageEntity: nugetPackage }); + + expect(packageIcon(state)).toBe(nugetPackage.nuget_metadatum.icon_url); + }); + + it('should return null when nuget package does not have an icon', () => { + setupState({ packageEntity: { ...nugetPackage, nuget_metadatum: {} } }); + + expect(packageIcon(state)).toBe(null); + }); + }); + + it('should not find icons for other package types', () => { + setupState({ packageEntity: npmPackage }); + + expect(packageIcon(state)).toBe(null); + }); + }); + + describe('conan string getters', () => { + it('gets the correct conanInstallationCommand', () => { + setupState({ packageEntity: conanPackage }); + + expect(conanInstallationCommand(state)).toBe(conanInstallationCommandStr); + }); + + it('gets the correct conanSetupCommand', () => { + setupState({ packageEntity: conanPackage }); + + expect(conanSetupCommand(state)).toBe(conanSetupCommandStr); + }); + }); + + describe('maven string getters', () => { + it('gets the correct mavenInstallationXml', () => { + setupState(); + + expect(mavenInstallationXml(state)).toBe(mavenInstallationXmlBlock); + }); + + it('gets the correct mavenInstallationCommand', () => { + setupState(); + + expect(mavenInstallationCommand(state)).toBe(mavenCommandStr); + }); + + it('gets the correct mavenSetupXml', () => { + setupState(); + + expect(mavenSetupXml(state)).toBe(mavenSetupXmlBlock); + }); + }); + + describe('npm string getters', () => { + it('gets the correct npmInstallationCommand for NPM', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmInstallationCommand(state)(NpmManager.NPM)).toBe(npmInstallStr); + }); + + it('gets the correct npmSetupCommand for NPM', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmSetupCommand(state)(NpmManager.NPM)).toBe(npmSetupStr); + }); + + it('gets the correct npmInstallationCommand for Yarn', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmInstallationCommand(state)(NpmManager.YARN)).toBe(yarnInstallStr); + }); + + it('gets the correct npmSetupCommand for Yarn', () => { + setupState({ packageEntity: npmPackage }); + + expect(npmSetupCommand(state)(NpmManager.YARN)).toBe(yarnSetupStr); + }); + }); + + describe('nuget string getters', () => { + it('gets the correct nugetInstallationCommand', () => { + setupState({ packageEntity: nugetPackage }); + + expect(nugetInstallationCommand(state)).toBe(nugetInstallationCommandStr); + }); + + it('gets the correct nugetSetupCommand', () => { + setupState({ packageEntity: nugetPackage }); + + expect(nugetSetupCommand(state)).toBe(nugetSetupCommandStr); + }); + }); + + describe('pypi string getters', () => { + it('gets the correct pypiPipCommand', () => { + setupState({ packageEntity: pypiPackage }); + + expect(pypiPipCommand(state)).toBe(pypiPipCommandStr); + }); + + it('gets the correct pypiSetupCommand', () => { + setupState({ pypiSetupPath: 'foo' }); + + expect(pypiSetupCommand(state)).toBe(pypiSetupCommandStr); + }); + }); + + describe('composer string getters', () => { + it('gets the correct composerRegistryInclude command', () => { + setupState({ composerPath: 'foo' }); + + expect(composerRegistryInclude(state)).toBe(composerRegistryIncludeStr); + }); + + it('gets the correct composerPackageInclude command', () => { + setupState(); + + expect(composerPackageInclude(state)).toBe(composerPackageIncludeStr); + }); + }); +}); diff --git a/spec/frontend/packages/details/store/mutations_spec.js b/spec/frontend/packages/details/store/mutations_spec.js new file mode 100644 index 00000000000..501a56dcdde --- /dev/null +++ b/spec/frontend/packages/details/store/mutations_spec.js @@ -0,0 +1,31 @@ +import mutations from '~/packages/details/store/mutations'; +import * as types from '~/packages/details/store/mutation_types'; +import { npmPackage as packageEntity } from '../../mock_data'; + +describe('Mutations package details Store', () => { + let mockState; + + beforeEach(() => { + mockState = { + packageEntity, + }; + }); + + describe('SET_LOADING', () => { + it('should set loading', () => { + mutations[types.SET_LOADING](mockState, true); + + expect(mockState.isLoading).toEqual(true); + }); + }); + + describe('SET_PACKAGE_VERSIONS', () => { + it('should set the package entity versions', () => { + const fakeVersions = [1, 2, 3]; + + mutations[types.SET_PACKAGE_VERSIONS](mockState, fakeVersions); + + expect(mockState.packageEntity.versions).toEqual(fakeVersions); + }); + }); +}); diff --git a/spec/frontend/packages/details/utils_spec.js b/spec/frontend/packages/details/utils_spec.js new file mode 100644 index 00000000000..087888016ee --- /dev/null +++ b/spec/frontend/packages/details/utils_spec.js @@ -0,0 +1,24 @@ +import { generateConanRecipe } from '~/packages/details/utils'; +import { conanPackage } from '../mock_data'; + +describe('Package detail utils', () => { + describe('generateConanRecipe', () => { + it('correctly generates the conan recipe', () => { + const recipe = generateConanRecipe(conanPackage); + + expect(recipe).toEqual(conanPackage.recipe); + }); + + it('returns an empty recipe when no information is supplied', () => { + const recipe = generateConanRecipe({}); + + expect(recipe).toEqual('/@/'); + }); + + it('recipe returns empty strings for missing metadata', () => { + const recipe = generateConanRecipe({ name: 'foo', version: '0.0.1' }); + + expect(recipe).toBe('foo/0.0.1@/'); + }); + }); +}); diff --git a/spec/frontend/packages/list/coming_soon/helpers_spec.js b/spec/frontend/packages/list/coming_soon/helpers_spec.js new file mode 100644 index 00000000000..4a996bfad76 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/helpers_spec.js @@ -0,0 +1,36 @@ +import * as comingSoon from '~/packages/list/coming_soon/helpers'; +import { fakeIssues, asGraphQLResponse, asViewModel } from './mock_data'; + +jest.mock('~/api.js'); + +describe('Coming Soon Helpers', () => { + const [noLabels, acceptingMergeRequestLabel, workflowLabel] = fakeIssues; + + describe('toViewModel', () => { + it('formats a GraphQL response correctly', () => { + expect(comingSoon.toViewModel(asGraphQLResponse)).toEqual(asViewModel); + }); + }); + + describe('findWorkflowLabel', () => { + it('finds a workflow label', () => { + expect(comingSoon.findWorkflowLabel(workflowLabel.labels)).toEqual(workflowLabel.labels[0]); + }); + + it("returns undefined when there isn't one", () => { + expect(comingSoon.findWorkflowLabel(noLabels.labels)).toBeUndefined(); + }); + }); + + describe('findAcceptingContributionsLabel', () => { + it('finds the correct label when it exists', () => { + expect(comingSoon.findAcceptingContributionsLabel(acceptingMergeRequestLabel.labels)).toEqual( + acceptingMergeRequestLabel.labels[0], + ); + }); + + it("returns undefined when there isn't one", () => { + expect(comingSoon.findAcceptingContributionsLabel(noLabels.labels)).toBeUndefined(); + }); + }); +}); diff --git a/spec/frontend/packages/list/coming_soon/mock_data.js b/spec/frontend/packages/list/coming_soon/mock_data.js new file mode 100644 index 00000000000..bb4568e4bd5 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/mock_data.js @@ -0,0 +1,90 @@ +export const fakeIssues = [ + { + id: 1, + iid: 1, + title: 'issue one', + webUrl: 'foo', + }, + { + id: 2, + iid: 2, + title: 'issue two', + labels: [{ title: 'Accepting merge requests', color: '#69d100' }], + milestone: { + title: '12.10', + }, + webUrl: 'foo', + }, + { + id: 3, + iid: 3, + title: 'issue three', + labels: [{ title: 'workflow::In dev', color: '#428bca' }], + webUrl: 'foo', + }, + { + id: 4, + iid: 4, + title: 'issue four', + labels: [ + { title: 'Accepting merge requests', color: '#69d100' }, + { title: 'workflow::In dev', color: '#428bca' }, + ], + webUrl: 'foo', + }, +]; + +export const asGraphQLResponse = { + project: { + issues: { + nodes: fakeIssues.map(x => ({ + ...x, + labels: { + nodes: x.labels, + }, + })), + }, + }, +}; + +export const asViewModel = [ + { + ...fakeIssues[0], + labels: [], + }, + { + ...fakeIssues[1], + labels: [ + { + title: 'Accepting merge requests', + color: '#69d100', + scoped: false, + }, + ], + }, + { + ...fakeIssues[2], + labels: [ + { + title: 'workflow::In dev', + color: '#428bca', + scoped: true, + }, + ], + }, + { + ...fakeIssues[3], + labels: [ + { + title: 'workflow::In dev', + color: '#428bca', + scoped: true, + }, + { + title: 'Accepting merge requests', + color: '#69d100', + scoped: false, + }, + ], + }, +]; diff --git a/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js new file mode 100644 index 00000000000..c4cdadc45e6 --- /dev/null +++ b/spec/frontend/packages/list/coming_soon/packages_coming_soon_spec.js @@ -0,0 +1,138 @@ +import { GlEmptyState, GlSkeletonLoader, GlLabel } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import VueApollo, { ApolloQuery } from 'vue-apollo'; +import ComingSoon from '~/packages/list/coming_soon/packages_coming_soon.vue'; +import { TrackingActions } from '~/packages/shared/constants'; +import { asViewModel } from './mock_data'; +import Tracking from '~/tracking'; + +jest.mock('~/packages/list/coming_soon/helpers.js'); + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +describe('packages_coming_soon', () => { + let wrapper; + + const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader); + const findAllIssues = () => wrapper.findAll('[data-testid="issue-row"]'); + const findIssuesData = () => + findAllIssues().wrappers.map(x => { + const titleLink = x.find('[data-testid="issue-title-link"]'); + const milestone = x.find('[data-testid="milestone"]'); + const issueIdLink = x.find('[data-testid="issue-id-link"]'); + const labels = x.findAll(GlLabel); + + const issueId = Number(issueIdLink.text().substr(1)); + + return { + id: issueId, + iid: issueId, + title: titleLink.text(), + webUrl: titleLink.attributes('href'), + labels: labels.wrappers.map(label => ({ + color: label.props('backgroundColor'), + title: label.props('title'), + scoped: label.props('scoped'), + })), + ...(milestone.exists() ? { milestone: { title: milestone.text() } } : {}), + }; + }); + const findIssueTitleLink = () => wrapper.find('[data-testid="issue-title-link"]'); + const findIssueIdLink = () => wrapper.find('[data-testid="issue-id-link"]'); + const findEmptyState = () => wrapper.find(GlEmptyState); + + const mountComponent = (testParams = {}) => { + const $apolloData = { + loading: testParams.isLoading || false, + }; + + wrapper = mount(ComingSoon, { + localVue, + propsData: { + illustration: 'foo', + projectPath: 'foo', + suggestedContributionsPath: 'foo', + }, + stubs: { + ApolloQuery, + GlLink: true, + }, + mocks: { + $apolloData, + }, + }); + + // Mock the GraphQL query result + wrapper.find(ApolloQuery).setData({ + result: { + data: testParams.issues || asViewModel, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when loading', () => { + beforeEach(() => mountComponent({ isLoading: true })); + + it('renders the skeleton loader', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + }); + + describe('when there are no issues', () => { + beforeEach(() => mountComponent({ issues: [] })); + + it('renders the empty state', () => { + expect(findEmptyState().exists()).toBe(true); + }); + }); + + describe('when there are issues', () => { + beforeEach(() => mountComponent()); + + it('renders each issue', () => { + expect(findIssuesData()).toEqual(asViewModel); + }); + }); + + describe('tracking', () => { + const firstIssue = asViewModel[0]; + let eventSpy; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + mountComponent(); + }); + + it('tracks when mounted', () => { + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_REQUESTED, {}); + }); + + it('tracks when an issue title link is clicked', () => { + eventSpy.mockClear(); + + findIssueTitleLink().vm.$emit('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { + label: firstIssue.title, + value: firstIssue.iid, + }); + }); + + it('tracks when an issue id link is clicked', () => { + eventSpy.mockClear(); + + findIssueIdLink().vm.$emit('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, TrackingActions.COMING_SOON_LIST, { + label: firstIssue.title, + value: firstIssue.iid, + }); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap new file mode 100644 index 00000000000..ed77f25916f --- /dev/null +++ b/spec/frontend/packages/list/components/__snapshots__/packages_filter_spec.js.snap @@ -0,0 +1,14 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_filter renders 1`] = ` +<gl-search-box-by-click-stub + clearable="true" + clearbuttontitle="Clear" + clearrecentsearchestext="Clear recent searches" + closebuttontitle="Close" + norecentsearchestext="You don't have any recent searches" + placeholder="Filter by name" + recentsearchesheader="Recent searches" + value="" +/> +`; 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 new file mode 100644 index 00000000000..2b7a4c83bed --- /dev/null +++ b/spec/frontend/packages/list/components/__snapshots__/packages_list_app_spec.js.snap @@ -0,0 +1,457 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_app renders 1`] = ` +<b-tabs-stub + activenavitemclass="gl-tab-nav-item-active gl-tab-nav-item-active-indigo" + class="gl-tabs" + contentclass=",gl-tab-content" + navclass="gl-tabs-nav" + nofade="true" + nonavstyle="true" + tag="div" +> + <template> + + <b-tab-stub + tag="div" + title="All" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Composer" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Composer packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Composer packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Conan" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Conan packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Conan packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="Maven" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no Maven packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no Maven packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="NPM" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no NPM packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no NPM packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="NuGet" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no NuGet packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no NuGet packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + <b-tab-stub + tag="div" + title="PyPi" + titlelinkclass="gl-tab-nav-item" + > + <template> + <div> + <section + class="row empty-state text-center" + > + <div + class="col-12" + > + <div + class="svg-250 svg-content" + > + <img + alt="There are no PyPi packages yet" + class="gl-max-w-full" + src="helpSvg" + /> + </div> + </div> + + <div + class="col-12" + > + <div + class="text-content gl-mx-auto gl-my-0 gl-p-5" + > + <h1 + class="h4" + > + There are no PyPi packages yet + </h1> + + <p> + Learn how to + <b-link-stub + class="gl-link" + event="click" + href="helpUrl" + routertag="a" + target="_blank" + > + publish and share your packages + </b-link-stub> + with GitLab. + </p> + + <div> + <!----> + + <!----> + </div> + </div> + </div> + </section> + </div> + </template> + </b-tab-stub> + + <!----> + </template> + <template> + <div + class="d-flex align-self-center ml-md-auto py-1 py-md-0" + > + <package-filter-stub + class="mr-1" + /> + + <package-sort-stub /> + </div> + </template> +</b-tabs-stub> +`; diff --git a/spec/frontend/packages/list/components/packages_filter_spec.js b/spec/frontend/packages/list/components/packages_filter_spec.js new file mode 100644 index 00000000000..b186b5f5e48 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_filter_spec.js @@ -0,0 +1,50 @@ +import Vuex from 'vuex'; +import { GlSearchBoxByClick } from '@gitlab/ui'; +import { createLocalVue, shallowMount } from '@vue/test-utils'; +import PackagesFilter from '~/packages/list/components/packages_filter.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_filter', () => { + let wrapper; + let store; + + const findGlSearchBox = () => wrapper.find(GlSearchBoxByClick); + + const mountComponent = () => { + store = new Vuex.Store(); + store.dispatch = jest.fn(); + + wrapper = shallowMount(PackagesFilter, { + localVue, + store, + }); + }; + + beforeEach(mountComponent); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('emits events', () => { + it('sets the filter value in the store on input', () => { + const searchString = 'foo'; + findGlSearchBox().vm.$emit('input', searchString); + + expect(store.dispatch).toHaveBeenCalledWith('setFilter', searchString); + }); + + it('emits the filter event when search box is submitted', () => { + findGlSearchBox().vm.$emit('submit'); + + expect(wrapper.emitted('filter')).toBeTruthy(); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_list_app_spec.js b/spec/frontend/packages/list/components/packages_list_app_spec.js new file mode 100644 index 00000000000..31bab3886c1 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_list_app_spec.js @@ -0,0 +1,148 @@ +import Vuex from 'vuex'; +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { GlEmptyState, GlTab, GlTabs, GlSprintf, GlLink } from '@gitlab/ui'; +import PackageListApp from '~/packages/list/components/packages_list_app.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_list_app', () => { + let wrapper; + let store; + + const PackageList = { + name: 'package-list', + template: '<div><slot name="empty-state"></slot></div>', + }; + const GlLoadingIcon = { name: 'gl-loading-icon', template: '<div>loading</div>' }; + + const emptyListHelpUrl = 'helpUrl'; + const findEmptyState = () => wrapper.find(GlEmptyState); + const findListComponent = () => wrapper.find(PackageList); + const findTabComponent = (index = 0) => wrapper.findAll(GlTab).at(index); + + const createStore = (filterQuery = '') => { + store = new Vuex.Store({ + state: { + isLoading: false, + config: { + resourceId: 'project_id', + emptyListIllustration: 'helpSvg', + emptyListHelpUrl, + }, + filterQuery, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = () => { + wrapper = shallowMount(PackageListApp, { + localVue, + store, + stubs: { + GlEmptyState, + GlLoadingIcon, + PackageList, + GlTab, + GlTabs, + GlSprintf, + GlLink, + }, + }); + }; + + beforeEach(() => { + createStore(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('empty state', () => { + it('generate the correct empty list link', () => { + mountComponent(); + + const link = findListComponent().find(GlLink); + + expect(link.attributes('href')).toBe(emptyListHelpUrl); + expect(link.text()).toBe('publish and share your packages'); + }); + + it('includes the right content on the default tab', () => { + mountComponent(); + + const heading = findEmptyState().find('h1'); + + expect(heading.text()).toBe('There are no packages yet'); + }); + }); + + it('call requestPackagesList on page:changed', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('page:changed', 1); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList', { page: 1 }); + }); + + it('call requestDeletePackage on package:delete', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('package:delete', 'foo'); + expect(store.dispatch).toHaveBeenCalledWith('requestDeletePackage', 'foo'); + }); + + it('calls requestPackagesList on sort:changed', () => { + mountComponent(); + + const list = findListComponent(); + list.vm.$emit('sort:changed'); + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + + it('does not call requestPackagesList two times on render', () => { + mountComponent(); + + expect(store.dispatch).toHaveBeenCalledTimes(1); + }); + + describe('tab change', () => { + it('calls requestPackagesList when all tab is clicked', () => { + mountComponent(); + + findTabComponent().trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + + it('calls requestPackagesList when a package type tab is clicked', () => { + mountComponent(); + + findTabComponent(1).trigger('click'); + + expect(store.dispatch).toHaveBeenCalledWith('requestPackagesList'); + }); + }); + + describe('filter without results', () => { + beforeEach(() => { + createStore('foo'); + mountComponent(); + }); + + it('should show specific empty message', () => { + expect(findEmptyState().text()).toContain('Sorry, your filter produced no results'); + expect(findEmptyState().text()).toContain( + 'To widen your search, change or remove the filters above', + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_list_spec.js b/spec/frontend/packages/list/components/packages_list_spec.js new file mode 100644 index 00000000000..a90d5056212 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_list_spec.js @@ -0,0 +1,219 @@ +import Vuex from 'vuex'; +import { last } from 'lodash'; +import { GlTable, GlPagination, GlModal } from '@gitlab/ui'; +import { mount, createLocalVue } from '@vue/test-utils'; +import stubChildren from 'helpers/stub_children'; +import Tracking from '~/tracking'; +import PackagesList from '~/packages/list/components/packages_list.vue'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; +import PackagesListRow from '~/packages/shared/components/package_list_row.vue'; +import * as SharedUtils from '~/packages/shared/utils'; +import { TrackingActions } from '~/packages/shared/constants'; +import { packageList } from '../../mock_data'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +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 findPackagesListRow = () => wrapper.find(PackagesListRow); + + const createStore = (isGroupPage, packages, isLoading) => { + const state = { + isLoading, + packages, + pagination: { + perPage: 1, + total: 1, + page: 1, + }, + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + getters: { + getList: () => packages, + }, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = ({ + isGroupPage = false, + packages = packageList, + isLoading = false, + ...options + } = {}) => { + createStore(isGroupPage, packages, isLoading); + + wrapper = mount(PackagesList, { + localVue, + store, + stubs: { + ...stubChildren(PackagesList), + GlTable, + GlSortingItem, + GlModal, + }, + ...options, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is loading', () => { + beforeEach(() => { + mountComponent({ + packages: [], + isLoading: true, + }); + }); + + it('shows skeleton loader when loading', () => { + expect(findPackagesListLoader().exists()).toBe(true); + }); + }); + + describe('when is not loading', () => { + beforeEach(() => { + mountComponent(); + }); + + it('does not show skeleton loader when not loading', () => { + expect(findPackagesListLoader().exists()).toBe(false); + }); + }); + + describe('layout', () => { + beforeEach(() => { + mountComponent(); + }); + + it('contains a pagination component', () => { + const sorting = findPackageListPagination(); + expect(sorting.exists()).toBe(true); + }); + + it('contains a modal component', () => { + const sorting = findPackageListDeleteModal(); + expect(sorting.exists()).toBe(true); + }); + }); + + describe('when the user can destroy the package', () => { + beforeEach(() => { + mountComponent(); + }); + + it('setItemToBeDeleted sets itemToBeDeleted and open the modal', () => { + const mockModalShow = jest.spyOn(wrapper.vm.$refs.packageListDeleteModal, 'show'); + const item = last(wrapper.vm.list); + + findPackagesListRow().vm.$emit('packageToDelete', item); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.vm.itemToBeDeleted).toEqual(item); + expect(mockModalShow).toHaveBeenCalled(); + }); + }); + + it('deleteItemConfirmation resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemConfirmation(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + + it('deleteItemConfirmation emit package:delete', () => { + const itemToBeDeleted = { id: 2 }; + wrapper.setData({ itemToBeDeleted }); + wrapper.vm.deleteItemConfirmation(); + return wrapper.vm.$nextTick(() => { + expect(wrapper.emitted('package:delete')[0]).toEqual([itemToBeDeleted]); + }); + }); + + it('deleteItemCanceled resets itemToBeDeleted', () => { + wrapper.setData({ itemToBeDeleted: 1 }); + wrapper.vm.deleteItemCanceled(); + expect(wrapper.vm.itemToBeDeleted).toEqual(null); + }); + }); + + describe('when the list is empty', () => { + beforeEach(() => { + mountComponent({ + packages: [], + slots: { + 'empty-state': EmptySlotStub, + }, + }); + }); + + it('show the empty slot', () => { + const emptySlot = findEmptySlot(); + expect(emptySlot.exists()).toBe(true); + }); + }); + + describe('pagination component', () => { + let pagination; + let modelEvent; + + beforeEach(() => { + mountComponent(); + pagination = findPackageListPagination(); + // retrieve the event used by v-model, a more sturdy approach than hardcoding it + modelEvent = pagination.vm.$options.model.event; + }); + + it('emits page:changed events when the page changes', () => { + pagination.vm.$emit(modelEvent, 2); + expect(wrapper.emitted('page:changed')).toEqual([[2]]); + }); + }); + + describe('tracking', () => { + let eventSpy; + let utilSpy; + const category = 'foo'; + + beforeEach(() => { + mountComponent(); + eventSpy = jest.spyOn(Tracking, 'event'); + utilSpy = jest.spyOn(SharedUtils, 'packageTypeToTrackCategory').mockReturnValue(category); + wrapper.setData({ itemToBeDeleted: { package_type: 'conan' } }); + }); + + it('tracking category calls packageTypeToTrackCategory', () => { + expect(wrapper.vm.tracking.category).toBe(category); + expect(utilSpy).toHaveBeenCalledWith('conan'); + }); + + it('deleteItemConfirmation calls event', () => { + wrapper.vm.deleteItemConfirmation(); + expect(eventSpy).toHaveBeenCalledWith( + category, + TrackingActions.DELETE_PACKAGE, + expect.any(Object), + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/components/packages_sort_spec.js b/spec/frontend/packages/list/components/packages_sort_spec.js new file mode 100644 index 00000000000..ff3e8e19413 --- /dev/null +++ b/spec/frontend/packages/list/components/packages_sort_spec.js @@ -0,0 +1,92 @@ +import Vuex from 'vuex'; +import { GlSorting } 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'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('packages_sort', () => { + let wrapper; + let store; + 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); + + const createStore = isGroupPage => { + const state = { + config: { + isGroupPage, + }, + sorting: { + orderBy: 'version', + sort: 'desc', + }, + }; + store = new Vuex.Store({ + state, + }); + store.dispatch = jest.fn(); + }; + + const mountComponent = (isGroupPage = false) => { + createStore(isGroupPage); + + wrapper = mount(PackagesSort, { + localVue, + store, + stubs: { + ...stubChildren(PackagesSort), + GlSortingItem, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when is in projects', () => { + beforeEach(() => { + mountComponent(); + sorting = findPackageListSorting(); + sortingItems = findSortingItems(); + }); + + it('has all the sortable items', () => { + expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length); + }); + + it('on sort change set sorting in vuex and emit event', () => { + sorting.vm.$emit('sortDirectionChange'); + expect(store.dispatch).toHaveBeenCalledWith('setSorting', { sort: 'asc' }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + + it('on sort item click set sorting and emit event', () => { + const item = sortingItems.at(0); + const { orderBy } = wrapper.vm.sortableFields[0]; + item.vm.$emit('click'); + expect(store.dispatch).toHaveBeenCalledWith('setSorting', { orderBy }); + expect(wrapper.emitted('sort:changed')).toBeTruthy(); + }); + }); + + describe('when is in group', () => { + beforeEach(() => { + mountComponent(true); + sorting = findPackageListSorting(); + sortingItems = findSortingItems(); + }); + + it('has all the sortable items', () => { + expect(sortingItems).toHaveLength(wrapper.vm.sortableFields.length); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/actions_spec.js b/spec/frontend/packages/list/stores/actions_spec.js new file mode 100644 index 00000000000..faa629cc01f --- /dev/null +++ b/spec/frontend/packages/list/stores/actions_spec.js @@ -0,0 +1,240 @@ +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; +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'; + +jest.mock('~/flash.js'); +jest.mock('~/api.js'); + +describe('Actions Package list store', () => { + const headers = 'bar'; + let mock; + + beforeEach(() => { + Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo', headers }); + Api.groupPackages = jest.fn().mockResolvedValue({ data: 'baz', headers }); + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + describe('requestPackagesList', () => { + const sorting = { + sort: 'asc', + orderBy: 'version', + }; + it('should fetch the project packages list when isGroupPage is false', done => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 1 }, sorting }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); + done(); + }, + ); + }); + + it('should fetch the group packages list when isGroupPage is true', done => { + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: true, resourceId: 2 }, sorting }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'baz', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.groupPackages).toHaveBeenCalledWith(2, { + params: { page: 1, per_page: 20, sort: sorting.sort, order_by: sorting.orderBy }, + }); + done(); + }, + ); + }); + + it('should fetch packages of a certain type when selectedType is present', done => { + const packageType = 'maven'; + + testAction( + actions.requestPackagesList, + undefined, + { + config: { isGroupPage: false, resourceId: 1 }, + sorting, + selectedType: { type: packageType }, + }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'receivePackagesListSuccess', payload: { data: 'foo', headers } }, + { type: 'setLoading', payload: false }, + ], + () => { + expect(Api.projectPackages).toHaveBeenCalledWith(1, { + params: { + page: 1, + per_page: 20, + sort: sorting.sort, + order_by: sorting.orderBy, + package_type: packageType, + }, + }); + done(); + }, + ); + }); + + it('should create flash on API error', done => { + Api.projectPackages = jest.fn().mockRejectedValue(); + testAction( + actions.requestPackagesList, + undefined, + { config: { isGroupPage: false, resourceId: 2 }, sorting }, + [], + [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + }); + + describe('receivePackagesListSuccess', () => { + it('should set received packages', done => { + const data = 'foo'; + + testAction( + actions.receivePackagesListSuccess, + { data, headers }, + null, + [ + { type: types.SET_PACKAGE_LIST_SUCCESS, payload: data }, + { type: types.SET_PAGINATION, payload: headers }, + ], + [], + done, + ); + }); + }); + + describe('setInitialState', () => { + it('should commit setInitialState', done => { + testAction( + actions.setInitialState, + '1', + null, + [{ type: types.SET_INITIAL_STATE, payload: '1' }], + [], + done, + ); + }); + }); + + describe('setLoading', () => { + it('should commit set main loading', done => { + testAction( + actions.setLoading, + true, + null, + [{ type: types.SET_MAIN_LOADING, payload: true }], + [], + done, + ); + }); + }); + + describe('requestDeletePackage', () => { + const payload = { + _links: { + delete_api_path: 'foo', + }, + }; + it('should perform a delete operation on _links.delete_api_path', done => { + mock.onDelete(payload._links.delete_api_path).replyOnce(200); + Api.projectPackages = jest.fn().mockResolvedValue({ data: 'foo' }); + + testAction( + actions.requestDeletePackage, + payload, + { pagination: { page: 1 } }, + [], + [ + { type: 'setLoading', payload: true }, + { type: 'requestPackagesList', payload: { page: 1 } }, + ], + done, + ); + }); + + it('should stop the loading and call create flash on api error', done => { + mock.onDelete(payload._links.delete_api_path).replyOnce(400); + testAction( + actions.requestDeletePackage, + payload, + null, + [], + [{ type: 'setLoading', payload: true }, { type: 'setLoading', payload: false }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); + }); + + it.each` + property | actionPayload + ${'_links'} | ${{}} + ${'delete_api_path'} | ${{ _links: {} }} + `('should reject and createFlash when $property is missing', ({ actionPayload }, done) => { + testAction(actions.requestDeletePackage, actionPayload, null, [], []).catch(e => { + expect(e).toEqual(new Error(MISSING_DELETE_PATH_ERROR)); + expect(createFlash).toHaveBeenCalledWith(DELETE_PACKAGE_ERROR_MESSAGE); + done(); + }); + }); + }); + + describe('setSorting', () => { + it('should commit SET_SORTING', done => { + testAction( + actions.setSorting, + 'foo', + null, + [{ type: types.SET_SORTING, payload: 'foo' }], + [], + done, + ); + }); + }); + + describe('setFilter', () => { + it('should commit SET_FILTER', done => { + testAction( + actions.setFilter, + 'foo', + null, + [{ type: types.SET_FILTER, payload: 'foo' }], + [], + done, + ); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/getters_spec.js b/spec/frontend/packages/list/stores/getters_spec.js new file mode 100644 index 00000000000..080bbc21d9f --- /dev/null +++ b/spec/frontend/packages/list/stores/getters_spec.js @@ -0,0 +1,36 @@ +import getList from '~/packages/list/stores/getters'; +import { packageList } from '../../mock_data'; + +describe('Getters registry list store', () => { + let state; + + const setState = ({ isGroupPage = false } = {}) => { + state = { + packages: packageList, + config: { + isGroupPage, + }, + }; + }; + + beforeEach(() => setState()); + + afterEach(() => { + state = null; + }); + + describe('getList', () => { + it('returns a list of packages', () => { + const result = getList(state); + + expect(result).toHaveLength(packageList.length); + expect(result[0].name).toBe('Test package'); + }); + + it('adds projectPathName', () => { + const result = getList(state); + + expect(result[0].projectPathName).toMatchInlineSnapshot(`"foo / bar / baz"`); + }); + }); +}); diff --git a/spec/frontend/packages/list/stores/mutations_spec.js b/spec/frontend/packages/list/stores/mutations_spec.js new file mode 100644 index 00000000000..563a3dabbb3 --- /dev/null +++ b/spec/frontend/packages/list/stores/mutations_spec.js @@ -0,0 +1,95 @@ +import mutations from '~/packages/list/stores/mutations'; +import * as types from '~/packages/list/stores/mutation_types'; +import createState from '~/packages/list/stores/state'; +import * as commonUtils from '~/lib/utils/common_utils'; +import { npmPackage, mavenPackage } from '../../mock_data'; + +describe('Mutations Registry Store', () => { + let mockState; + beforeEach(() => { + mockState = createState(); + }); + + describe('SET_INITIAL_STATE', () => { + it('should set the initial state', () => { + const config = { + resourceId: '1', + pageType: 'groups', + userCanDelete: '', + emptyListIllustration: 'foo', + emptyListHelpUrl: 'baz', + comingSoonJson: '{ "project_path": "gitlab-org/gitlab-test" }', + }; + + const expectedState = { + ...mockState, + config: { + ...config, + isGroupPage: true, + canDestroyPackage: true, + }, + }; + mutations[types.SET_INITIAL_STATE](mockState, config); + + expect(mockState.projectId).toEqual(expectedState.projectId); + }); + }); + + describe('SET_PACKAGE_LIST_SUCCESS', () => { + it('should set a packages list', () => { + const payload = [npmPackage, mavenPackage]; + const expectedState = { ...mockState, packages: payload }; + mutations[types.SET_PACKAGE_LIST_SUCCESS](mockState, payload); + + expect(mockState.packages).toEqual(expectedState.packages); + }); + }); + + describe('SET_MAIN_LOADING', () => { + it('should set main loading', () => { + mutations[types.SET_MAIN_LOADING](mockState, true); + + expect(mockState.isLoading).toEqual(true); + }); + }); + + describe('SET_PAGINATION', () => { + const mockPagination = { perPage: 10, page: 1 }; + beforeEach(() => { + commonUtils.normalizeHeaders = jest.fn().mockReturnValue('baz'); + commonUtils.parseIntPagination = jest.fn().mockReturnValue(mockPagination); + }); + it('should set a parsed pagination', () => { + mutations[types.SET_PAGINATION](mockState, 'foo'); + expect(commonUtils.normalizeHeaders).toHaveBeenCalledWith('foo'); + expect(commonUtils.parseIntPagination).toHaveBeenCalledWith('baz'); + expect(mockState.pagination).toEqual(mockPagination); + }); + }); + + describe('SET_SORTING', () => { + it('should merge the sorting object with sort value', () => { + mutations[types.SET_SORTING](mockState, { sort: 'desc' }); + expect(mockState.sorting).toEqual({ ...mockState.sorting, sort: 'desc' }); + }); + + it('should merge the sorting object with order_by value', () => { + mutations[types.SET_SORTING](mockState, { orderBy: 'foo' }); + expect(mockState.sorting).toEqual({ ...mockState.sorting, orderBy: 'foo' }); + }); + }); + + describe('SET_SELECTED_TYPE', () => { + it('should set the selected type', () => { + mutations[types.SET_SELECTED_TYPE](mockState, { type: 'maven' }); + expect(mockState.selectedType).toEqual({ type: 'maven' }); + }); + }); + + describe('SET_FILTER', () => { + it('should set the filter query', () => { + mutations[types.SET_FILTER](mockState, 'foo'); + expect(mockState.filterQuery).toEqual('foo'); + }); + }); +}); diff --git a/spec/frontend/packages/list/utils_spec.js b/spec/frontend/packages/list/utils_spec.js new file mode 100644 index 00000000000..5bcc3784752 --- /dev/null +++ b/spec/frontend/packages/list/utils_spec.js @@ -0,0 +1,39 @@ +import { getNewPaginationPage } from '~/packages/list/utils'; + +describe('Packages list utils', () => { + describe('packageTypeDisplay', () => { + it('returns the current page when total items exceeds pagniation', () => { + expect(getNewPaginationPage(2, 20, 21)).toBe(2); + }); + + it('returns the previous page when total items is lower than or equal to pagination', () => { + expect(getNewPaginationPage(2, 20, 20)).toBe(1); + }); + + it('returns the first page when totalItems is lower than or equal to perPage', () => { + expect(getNewPaginationPage(4, 20, 20)).toBe(1); + }); + + describe('works when a different perPage is used', () => { + it('returns the current page', () => { + expect(getNewPaginationPage(2, 10, 11)).toBe(2); + }); + + it('returns the previous page', () => { + expect(getNewPaginationPage(2, 10, 10)).toBe(1); + }); + }); + + describe.each` + currentPage | totalItems | expectedResult + ${1} | ${20} | ${1} + ${2} | ${20} | ${1} + ${3} | ${40} | ${2} + ${4} | ${60} | ${3} + `(`works across numerious pages`, ({ currentPage, totalItems, expectedResult }) => { + it(`when currentPage is ${currentPage} return to the previous page ${expectedResult}`, () => { + expect(getNewPaginationPage(currentPage, 20, totalItems)).toBe(expectedResult); + }); + }); + }); +}); diff --git a/spec/frontend/packages/mock_data.js b/spec/frontend/packages/mock_data.js new file mode 100644 index 00000000000..86205b0744c --- /dev/null +++ b/spec/frontend/packages/mock_data.js @@ -0,0 +1,170 @@ +const _links = { + web_path: 'foo', + delete_api_path: 'bar', +}; + +export const mockPipelineInfo = { + id: 1, + ref: 'branch-name', + sha: 'sha-baz', + user: { + name: 'foo', + }, + project: { + name: 'foo-project', + web_url: 'foo-project-link', + commit_url: 'foo-commit-link', + pipeline_url: 'foo-pipeline-link', + }, + created_at: '2015-12-10', +}; + +export const mavenPackage = { + created_at: '2015-12-10', + id: 1, + maven_metadatum: { + app_group: 'com.test.app', + app_name: 'test-app', + app_version: '1.0-SNAPSHOT', + }, + name: 'Test package', + package_type: 'maven', + project_path: 'foo/bar/baz', + project_id: 1, + updated_at: '2015-12-10', + version: '1.0.0', + _links, +}; + +export const mavenFiles = [ + { + created_at: '2015-12-10', + file_name: 'File one', + id: 1, + size: 100, + download_path: '/-/package_files/1/download', + }, + { + created_at: '2015-12-10', + file_name: 'File two', + id: 2, + size: 200, + download_path: '/-/package_files/2/download', + }, +]; + +export const npmPackage = { + created_at: '2015-12-10', + id: 2, + name: '@Test/package', + package_type: 'npm', + project_path: 'foo/bar/baz', + project_id: 1, + updated_at: '2015-12-10', + version: '', + versions: [], + _links, + pipeline: mockPipelineInfo, +}; + +export const npmFiles = [ + { + created_at: '2015-12-10', + file_name: '@test/test-package-1.0.0.tgz', + id: 2, + size: 200, + download_path: '/-/package_files/2/download', + }, +]; + +export const conanPackage = { + conan_metadatum: { + package_channel: 'stable', + package_username: 'conan+conan-package', + }, + created_at: '2015-12-10', + id: 3, + name: 'conan-package', + project_path: 'foo/bar/baz', + package_files: [], + package_type: 'conan', + project_id: 1, + recipe: 'conan-package/1.0.0@conan+conan-package/stable', + updated_at: '2015-12-10', + version: '1.0.0', + _links, +}; + +export const dependencyLinks = { + withoutFramework: { name: 'Moqi', version_pattern: '2.5.6' }, + withoutVersion: { name: 'Castle.Core', version_pattern: '' }, + fullLink: { + name: 'Test.Dependency', + version_pattern: '2.3.7', + target_framework: '.NETStandard2.0', + }, + anotherFullLink: { + name: 'Newtonsoft.Json', + version_pattern: '12.0.3', + target_framework: '.NETStandard2.0', + }, +}; + +export const nugetPackage = { + created_at: '2015-12-10', + id: 4, + name: 'NugetPackage1', + package_files: [], + package_type: 'nuget', + project_id: 1, + tags: [], + updated_at: '2015-12-10', + version: '1.0.0', + dependency_links: Object.values(dependencyLinks), + nuget_metadatum: { + icon_url: 'fake-icon', + project_url: 'project-foo-url', + license_url: 'license-foo-url', + }, +}; + +export const pypiPackage = { + created_at: '2015-12-10', + id: 5, + name: 'PyPiPackage', + package_files: [], + package_type: 'pypi', + project_id: 1, + tags: [], + updated_at: '2015-12-10', + version: '1.0.0', +}; + +export const composerPackage = { + created_at: '2015-12-10', + id: 5, + name: 'ComposerPackage', + package_files: [], + package_type: 'composer', + project_id: 1, + tags: [], + updated_at: '2015-12-10', + version: '1.0.0', +}; + +export const mockTags = [ + { + name: 'foo-1', + }, + { + name: 'foo-2', + }, + { + name: 'foo-3', + }, + { + name: 'foo-4', + }, +]; + +export const packageList = [mavenPackage, { ...npmPackage, tags: mockTags }, conanPackage]; 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 new file mode 100644 index 00000000000..eab8d7b67cc --- /dev/null +++ b/spec/frontend/packages/shared/components/__snapshots__/package_list_row_spec.js.snap @@ -0,0 +1,101 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`packages_list_row renders 1`] = ` +<div + class="gl-responsive-table-row" + data-qa-selector="packages-row" +> + <div + class="table-section section-50 d-flex flex-md-column justify-content-between flex-wrap" + > + <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" + > + <span> + 1.0.0 + </span> + + <!----> + + <div + class="d-flex align-items-center" + > + <gl-icon-stub + class="text-secondary ml-2 mr-1" + name="review-list" + size="16" + /> + + <gl-link-stub + class="text-secondary" + data-testid="packages-row-project" + href="/foo/bar/baz" + > + + </gl-link-stub> + </div> + + <div + class="d-flex align-items-center" + data-testid="package-type" + > + <gl-icon-stub + class="text-secondary ml-2 mr-1" + name="package" + size="16" + /> + + <span> + Maven + </span> + </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" + > + <gl-sprintf-stub + message="Created %{timestamp}" + /> + </div> + </div> + + <div + class="table-section section-10 d-flex justify-content-end" + > + <gl-button-stub + aria-label="Remove package" + category="primary" + data-testid="action-delete" + icon="remove" + size="medium" + title="Remove package" + variant="danger" + /> + </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 new file mode 100644 index 00000000000..5ecca63d41d --- /dev/null +++ b/spec/frontend/packages/shared/components/__snapshots__/publish_method_spec.js.snap @@ -0,0 +1,39 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`publish_method renders 1`] = ` +<div + class="d-flex align-items-center text-secondary order-1 order-md-0 mb-md-1" +> + <gl-icon-stub + class="mr-1" + name="git-merge" + size="16" + /> + + <strong + class="mr-1 text-dark" + > + branch-name + </strong> + + <gl-icon-stub + class="mr-1" + name="commit" + size="16" + /> + + <gl-link-stub + class="mr-1" + href="../commit/sha-baz" + > + sha-baz + </gl-link-stub> + + <clipboard-button-stub + cssclass="border-0 text-secondary py-0 px-1" + text="sha-baz" + title="Copy commit SHA" + tooltipplacement="top" + /> +</div> +`; diff --git a/spec/frontend/packages/shared/components/package_list_row_spec.js b/spec/frontend/packages/shared/components/package_list_row_spec.js new file mode 100644 index 00000000000..c0ae972d519 --- /dev/null +++ b/spec/frontend/packages/shared/components/package_list_row_spec.js @@ -0,0 +1,106 @@ +import { mount, 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 { packageList } from '../../mock_data'; + +describe('packages_list_row', () => { + let wrapper; + let store; + + const [packageWithoutTags, packageWithTags] = packageList; + + const findPackageTags = () => wrapper.find(PackageTags); + const findProjectLink = () => wrapper.find('[data-testid="packages-row-project"]'); + const findDeleteButton = () => wrapper.find('[data-testid="action-delete"]'); + const findPackageType = () => wrapper.find('[data-testid="package-type"]'); + + const mountComponent = ({ + isGroup = false, + packageEntity = packageWithoutTags, + shallow = true, + showPackageType = true, + disableDelete = false, + } = {}) => { + const mountFunc = shallow ? shallowMount : mount; + + wrapper = mountFunc(PackagesListRow, { + store, + propsData: { + packageLink: 'foo', + packageEntity, + isGroup, + showPackageType, + disableDelete, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + mountComponent(); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('tags', () => { + it('renders package tags when a package has tags', () => { + mountComponent({ isGroup: false, packageEntity: packageWithTags }); + + expect(findPackageTags().exists()).toBe(true); + }); + + it('does not render when there are no tags', () => { + mountComponent(); + + expect(findPackageTags().exists()).toBe(false); + }); + }); + + describe('when is is group', () => { + beforeEach(() => { + mountComponent({ isGroup: true }); + }); + + it('has project field', () => { + expect(findProjectLink().exists()).toBe(true); + }); + }); + + describe('showPackageType', () => { + it('shows the type when set', () => { + mountComponent(); + + expect(findPackageType().exists()).toBe(true); + }); + + it('does not show the type when not set', () => { + mountComponent({ showPackageType: false }); + + expect(findPackageType().exists()).toBe(false); + }); + }); + + describe('deleteAvailable', () => { + it('does not show when not set', () => { + mountComponent({ disableDelete: true }); + + expect(findDeleteButton().exists()).toBe(false); + }); + }); + + describe('delete event', () => { + beforeEach(() => mountComponent({ packageEntity: packageWithoutTags, shallow: false })); + + it('emits the packageToDelete event when the delete button is clicked', () => { + findDeleteButton().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.emitted('packageToDelete')).toBeTruthy(); + expect(wrapper.emitted('packageToDelete')[0]).toEqual([packageWithoutTags]); + }); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/package_tags_spec.js b/spec/frontend/packages/shared/components/package_tags_spec.js new file mode 100644 index 00000000000..cc49a9a9244 --- /dev/null +++ b/spec/frontend/packages/shared/components/package_tags_spec.js @@ -0,0 +1,115 @@ +import { mount } from '@vue/test-utils'; +import PackageTags from '~/packages/shared/components/package_tags.vue'; +import { mockTags } from '../../mock_data'; + +describe('PackageTags', () => { + let wrapper; + + function createComponent(tags = [], props = {}) { + const propsData = { + tags, + ...props, + }; + + wrapper = mount(PackageTags, { + propsData, + }); + } + + const tagLabel = () => wrapper.find('[data-testid="tagLabel"]'); + const tagBadges = () => wrapper.findAll('[data-testid="tagBadge"]'); + const moreBadge = () => wrapper.find('[data-testid="moreBadge"]'); + + afterEach(() => { + if (wrapper) wrapper.destroy(); + }); + + describe('tag label', () => { + it('shows the tag label by default', () => { + createComponent(); + + expect(tagLabel().exists()).toBe(true); + }); + + it('hides when hideLabel prop is set to true', () => { + createComponent(mockTags, { hideLabel: true }); + + expect(tagLabel().exists()).toBe(false); + }); + }); + + it('renders the correct number of tags', () => { + createComponent(mockTags.slice(0, 2)); + + expect(tagBadges()).toHaveLength(2); + expect(moreBadge().exists()).toBe(false); + }); + + it('does not render more than the configured tagDisplayLimit', () => { + createComponent(mockTags); + + expect(tagBadges()).toHaveLength(2); + }); + + it('renders the more tags badge if there are more than the configured limit', () => { + createComponent(mockTags); + + expect(tagBadges()).toHaveLength(2); + expect(moreBadge().exists()).toBe(true); + expect(moreBadge().text()).toContain('2'); + }); + + it('renders the configured tagDisplayLimit when set in props', () => { + createComponent(mockTags, { tagDisplayLimit: 1 }); + + expect(tagBadges()).toHaveLength(1); + expect(moreBadge().exists()).toBe(true); + expect(moreBadge().text()).toContain('3'); + }); + + describe('tagBadgeStyle', () => { + const defaultStyle = ['badge', 'badge-info', 'gl-display-none']; + + it('shows tag badge when there is only one', () => { + createComponent([mockTags[0]]); + + const expectedStyle = [...defaultStyle, 'gl-display-flex', 'gl-ml-3']; + + expect( + tagBadges() + .at(0) + .classes(), + ).toEqual(expect.arrayContaining(expectedStyle)); + }); + + it('shows tag badge for medium or heigher resolutions', () => { + createComponent(mockTags); + + const expectedStyle = [...defaultStyle, 'd-md-flex']; + + expect( + tagBadges() + .at(1) + .classes(), + ).toEqual(expect.arrayContaining(expectedStyle)); + }); + + it('correctly prepends left and appends right when there is more than one tag', () => { + createComponent(mockTags, { + tagDisplayLimit: 4, + }); + + const expectedStyleWithoutAppend = [...defaultStyle, 'd-md-flex']; + const expectedStyleWithAppend = [...expectedStyleWithoutAppend, 'gl-mr-2']; + + const allBadges = tagBadges(); + + expect(allBadges.at(0).classes()).toEqual( + expect.arrayContaining([...expectedStyleWithAppend, 'gl-ml-3']), + ); + expect(allBadges.at(1).classes()).toEqual(expect.arrayContaining(expectedStyleWithAppend)); + expect(allBadges.at(2).classes()).toEqual(expect.arrayContaining(expectedStyleWithAppend)); + expect(allBadges.at(3).classes()).toEqual(expect.arrayContaining(expectedStyleWithoutAppend)); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/packages_list_loader_spec.js b/spec/frontend/packages/shared/components/packages_list_loader_spec.js new file mode 100644 index 00000000000..c8c2e2a4ba4 --- /dev/null +++ b/spec/frontend/packages/shared/components/packages_list_loader_spec.js @@ -0,0 +1,42 @@ +import { mount } from '@vue/test-utils'; +import PackagesListLoader from '~/packages/shared/components/packages_list_loader.vue'; + +describe('PackagesListLoader', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mount(PackagesListLoader, { + propsData: { + ...props, + }, + }); + }; + + const getShapes = () => wrapper.vm.desktopShapes; + const findSquareButton = () => wrapper.find({ ref: 'button-loader' }); + + beforeEach(createComponent); + + afterEach(() => { + wrapper.destroy(); + 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('when used for groups', () => { + beforeEach(() => { + createComponent({ isGroup: true }); + }); + + it('should return 5 rects with no square', () => { + expect(getShapes()).toHaveLength(5); + expect(findSquareButton().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/packages/shared/components/publish_method_spec.js b/spec/frontend/packages/shared/components/publish_method_spec.js new file mode 100644 index 00000000000..bb9287c1204 --- /dev/null +++ b/spec/frontend/packages/shared/components/publish_method_spec.js @@ -0,0 +1,50 @@ +import { shallowMount } from '@vue/test-utils'; +import PublishMethod from '~/packages/shared/components/publish_method.vue'; +import { packageList } from '../../mock_data'; + +describe('publish_method', () => { + let wrapper; + + 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 mountComponent = (packageEntity = {}, isGroup = false) => { + wrapper = shallowMount(PublishMethod, { + propsData: { + packageEntity, + isGroup, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('renders', () => { + mountComponent(packageWithPipeline); + expect(wrapper.element).toMatchSnapshot(); + }); + + describe('pipeline information', () => { + it('displays branch and commit when pipeline info exists', () => { + mountComponent(packageWithPipeline); + + expect(findPipelineRef().exists()).toBe(true); + expect(findPipelineSha().exists()).toBe(true); + }); + + it('does not show any pipeline details when no information exists', () => { + mountComponent(packageWithoutPipeline); + + expect(findPipelineRef().exists()).toBe(false); + expect(findPipelineSha().exists()).toBe(false); + expect(findManualPublish().exists()).toBe(true); + expect(findManualPublish().text()).toBe('Manually Published'); + }); + }); +}); diff --git a/spec/frontend/packages/shared/utils_spec.js b/spec/frontend/packages/shared/utils_spec.js new file mode 100644 index 00000000000..1fe90a4827f --- /dev/null +++ b/spec/frontend/packages/shared/utils_spec.js @@ -0,0 +1,66 @@ +import { + packageTypeToTrackCategory, + beautifyPath, + getPackageTypeLabel, + getCommitLink, +} from '~/packages/shared/utils'; +import { PackageType, TrackingCategories } from '~/packages/shared/constants'; +import { packageList } from '../mock_data'; + +describe('Packages shared utils', () => { + describe('packageTypeToTrackCategory', () => { + it('prepend UI to package category', () => { + expect(packageTypeToTrackCategory()).toMatchInlineSnapshot(`"UI::undefined"`); + }); + + it.each(Object.keys(PackageType))('returns a correct category string for %s', packageKey => { + const packageName = PackageType[packageKey]; + expect(packageTypeToTrackCategory(packageName)).toBe( + `UI::${TrackingCategories[packageName]}`, + ); + }); + }); + + describe('beautifyPath', () => { + it('returns a string with spaces around /', () => { + expect(beautifyPath('foo/bar')).toBe('foo / bar'); + }); + it('does not fail for empty string', () => { + expect(beautifyPath()).toBe(''); + }); + }); + + describe('getPackageTypeLabel', () => { + describe.each` + packageType | expectedResult + ${'conan'} | ${'Conan'} + ${'maven'} | ${'Maven'} + ${'npm'} | ${'NPM'} + ${'nuget'} | ${'NuGet'} + ${'pypi'} | ${'PyPi'} + ${'composer'} | ${'Composer'} + ${'foo'} | ${null} + `(`package type`, ({ packageType, expectedResult }) => { + it(`${packageType} should show as ${expectedResult}`, () => { + expect(getPackageTypeLabel(packageType)).toBe(expectedResult); + }); + }); + }); + + describe('getCommitLink', () => { + it('returns a relative link when isGroup is false', () => { + const link = getCommitLink(packageList[0], false); + + expect(link).toContain('../commit'); + }); + + describe('when isGroup is true', () => { + it('returns an absolute link matching project path', () => { + const mavenPackage = packageList[0]; + const link = getCommitLink(mavenPackage, true); + + expect(link).toContain(`/${mavenPackage.project_path}/commit`); + }); + }); + }); +}); diff --git a/spec/frontend/pager_spec.js b/spec/frontend/pager_spec.js index 47056c2804c..8b60f872bfd 100644 --- a/spec/frontend/pager_spec.js +++ b/spec/frontend/pager_spec.js @@ -1,9 +1,9 @@ import $ from 'jquery'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; import Pager from '~/pager'; import { removeParams } from '~/lib/utils/url_utility'; -import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/lib/utils/url_utility', () => ({ removeParams: jest.fn().mockName('removeParams'), diff --git a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js index fb7a07b7bc7..c662fb7ba4a 100644 --- a/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js +++ b/spec/frontend/pages/admin/jobs/index/components/stop_jobs_modal_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; -import { redirectTo } from '~/lib/utils/url_utility'; import mountComponent from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; +import { redirectTo } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; import stopJobsModal from '~/pages/admin/jobs/index/components/stop_jobs_modal.vue'; -import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), 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 82589e5147c..fc37a545511 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 @@ -37,29 +37,35 @@ exports[`User Operation confirmation modal renders modal with form included 1`] value="" /> </form> - <gl-deprecated-button-stub - size="md" - variant="secondary" + <gl-button-stub + category="primary" + icon="" + size="medium" + variant="default" > Cancel - </gl-deprecated-button-stub> + </gl-button-stub> - <gl-deprecated-button-stub + <gl-button-stub + category="primary" disabled="true" - size="md" + icon="" + size="medium" variant="warning" > secondaryAction - </gl-deprecated-button-stub> + </gl-button-stub> - <gl-deprecated-button-stub + <gl-button-stub + category="primary" disabled="true" - size="md" + icon="" + size="medium" variant="danger" > action - </gl-deprecated-button-stub> + </gl-button-stub> </div> `; diff --git a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js index 16b0bd305cd..3efefa8137f 100644 --- a/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js +++ b/spec/frontend/pages/admin/users/components/delete_user_modal_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton, GlFormInput } from '@gitlab/ui'; +import { GlButton, GlFormInput } from '@gitlab/ui'; import DeleteUserModal from '~/pages/admin/users/components/delete_user_modal.vue'; import ModalStub from './stubs/modal_stub'; @@ -13,7 +13,7 @@ describe('User Operation confirmation modal', () => { const findButton = variant => wrapper - .findAll(GlDeprecatedButton) + .findAll(GlButton) .filter(w => w.attributes('variant') === variant) .at(0); const findForm = () => wrapper.find('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 new file mode 100644 index 00000000000..b3a297ac2c5 --- /dev/null +++ b/spec/frontend/pages/dashboard/projects/index/components/customize_homepage_banner_spec.js @@ -0,0 +1,50 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlBanner } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import CustomizeHomepageBanner from '~/pages/dashboard/projects/index/components/customize_homepage_banner.vue'; +import axios from '~/lib/utils/axios_utils'; + +const svgPath = '/illustrations/background'; +const provide = { + svgPath, + preferencesBehaviorPath: 'some/behavior/path', + calloutsPath: 'call/out/path', + calloutsFeatureId: 'some-feature-id', +}; + +const createComponent = () => { + return shallowMount(CustomizeHomepageBanner, { provide }); +}; + +describe('CustomizeHomepageBanner', () => { + let mockAxios; + let wrapper; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + mockAxios.restore(); + }); + + it('should render the banner when not dismissed', () => { + expect(wrapper.contains(GlBanner)).toBe(true); + }); + + it('should close the banner when dismiss is clicked', async () => { + mockAxios.onPost(provide.calloutsPath).replyOnce(200); + expect(wrapper.contains(GlBanner)).toBe(true); + wrapper.find(GlBanner).vm.$emit('close'); + + await wrapper.vm.$nextTick(); + expect(wrapper.contains(GlBanner)).toBe(false); + }); + + it('includes the body text from options', () => { + expect(wrapper.html()).toContain(wrapper.vm.$options.i18n.body); + }); +}); diff --git a/spec/frontend/pages/labels/components/promote_label_modal_spec.js b/spec/frontend/pages/labels/components/promote_label_modal_spec.js index d4aabcc02f4..1fa12cf1365 100644 --- a/spec/frontend/pages/labels/components/promote_label_modal_spec.js +++ b/spec/frontend/pages/labels/components/promote_label_modal_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import promoteLabelModal from '~/pages/projects/labels/components/promote_label_modal.vue'; import eventHub from '~/pages/projects/labels/event_hub'; import axios from '~/lib/utils/axios_utils'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Promote label modal', () => { let vm; diff --git a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js index c376cf02594..1d9a964c3c3 100644 --- a/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js +++ b/spec/frontend/pages/milestones/shared/components/delete_milestone_modal_spec.js @@ -1,10 +1,10 @@ import Vue from 'vue'; -import { redirectTo } from '~/lib/utils/url_utility'; import mountComponent from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; +import { redirectTo } from '~/lib/utils/url_utility'; import axios from '~/lib/utils/axios_utils'; import deleteMilestoneModal from '~/pages/milestones/shared/components/delete_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; -import { TEST_HOST } from 'jest/helpers/test_constants'; jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), diff --git a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js index 87d32a67d47..e8a6e259837 100644 --- a/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js +++ b/spec/frontend/pages/milestones/shared/components/promote_milestone_modal_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import promoteMilestoneModal from '~/pages/milestones/shared/components/promote_milestone_modal.vue'; import eventHub from '~/pages/milestones/shared/event_hub'; import axios from '~/lib/utils/axios_utils'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('Promote milestone modal', () => { let vm; diff --git a/spec/frontend/pages/profiles/show/emoji_menu_spec.js b/spec/frontend/pages/profiles/show/emoji_menu_spec.js index 00320fb4601..08fc0b92424 100644 --- a/spec/frontend/pages/profiles/show/emoji_menu_spec.js +++ b/spec/frontend/pages/profiles/show/emoji_menu_spec.js @@ -55,7 +55,7 @@ describe('EmojiMenu', () => { }); }); - it('does not make an axios requst', done => { + it('does not make an axios request', done => { jest.spyOn(axios, 'request').mockReturnValue(); emojiMenu.addAward(dummyVotesBlock(), dummyAwardUrl, dummyEmoji, false, () => { 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 979dff78eba..2ec608569e3 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 @@ -1,14 +1,14 @@ import AxiosMockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; import { nextTick } from 'vue'; -import createFlash from '~/flash'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import ForkGroupsList from '~/pages/projects/forks/new/components/fork_groups_list.vue'; import ForkGroupsListItem from '~/pages/projects/forks/new/components/fork_groups_list_item.vue'; -import waitForPromises from 'helpers/wait_for_promises'; -jest.mock('~/flash', () => jest.fn()); +jest.mock('~/flash'); describe('Fork groups list component', () => { let wrapper; diff --git a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap index 94089ea922b..211f4ea20f5 100644 --- a/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap +++ b/spec/frontend/pages/projects/graphs/__snapshots__/code_coverage_spec.js.snap @@ -9,10 +9,10 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <!----> - <gl-dropdown-stub + <gl-deprecated-dropdown-stub text="rspec" > - <gl-dropdown-item-stub + <gl-deprecated-dropdown-item-stub value="rspec" > <div @@ -32,8 +32,8 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] </span> </div> - </gl-dropdown-item-stub> - <gl-dropdown-item-stub + </gl-deprecated-dropdown-item-stub> + <gl-deprecated-dropdown-item-stub value="cypress" > <div @@ -49,8 +49,8 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] </span> </div> - </gl-dropdown-item-stub> - <gl-dropdown-item-stub + </gl-deprecated-dropdown-item-stub> + <gl-deprecated-dropdown-item-stub value="karma" > <div @@ -66,8 +66,8 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] </span> </div> - </gl-dropdown-item-stub> - </gl-dropdown-stub> + </gl-deprecated-dropdown-item-stub> + </gl-deprecated-dropdown-stub> </div> <gl-area-chart-stub diff --git a/spec/frontend/pages/projects/graphs/code_coverage_spec.js b/spec/frontend/pages/projects/graphs/code_coverage_spec.js index 30c7ff78c6e..54a080fb62b 100644 --- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js +++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js @@ -1,12 +1,12 @@ import MockAdapter from 'axios-mock-adapter'; import { shallowMount } from '@vue/test-utils'; -import { GlAlert, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlIcon, GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; +import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import CodeCoverage from '~/pages/projects/graphs/components/code_coverage.vue'; import { codeCoverageMockData, sortedDataByDates } from './mock_data'; -import waitForPromises from 'helpers/wait_for_promises'; import httpStatusCodes from '~/lib/utils/http_status'; describe('Code Coverage', () => { @@ -17,7 +17,7 @@ describe('Code Coverage', () => { const findAlert = () => wrapper.find(GlAlert); const findAreaChart = () => wrapper.find(GlAreaChart); - const findAllDropdownItems = () => wrapper.findAll(GlDropdownItem); + const findAllDropdownItems = () => wrapper.findAll(GlDeprecatedDropdownItem); const findFirstDropdownItem = () => findAllDropdownItems().at(0); const findSecondDropdownItem = () => findAllDropdownItems().at(1); @@ -124,7 +124,7 @@ describe('Code Coverage', () => { }); it('renders the dropdown with all custom names as options', () => { - expect(wrapper.contains(GlDropdown)).toBeDefined(); + expect(wrapper.contains(GlDeprecatedDropdown)).toBeDefined(); expect(findAllDropdownItems()).toHaveLength(codeCoverageMockData.length); expect(findFirstDropdownItem().text()).toBe(codeCoverageMockData[0].group_name); }); 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 8917251d285..4c73225b54c 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,5 @@ import $ from 'jquery'; -import GLDropdown from '~/gl_dropdown'; // eslint-disable-line no-unused-vars +import '~/gl_dropdown'; import TimezoneDropdown, { formatUtcOffset, formatTimezone, 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 1f7eec567b8..a50ceed5d09 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 @@ -6,6 +6,8 @@ import { visibilityLevelDescriptions, visibilityOptions, } from '~/pages/projects/shared/permissions/constants'; +import projectFeatureSetting from '~/pages/projects/shared/permissions/components/project_feature_setting.vue'; +import projectFeatureToggle from '~/vue_shared/components/toggle_button.vue'; const defaultProps = { currentSettings: { @@ -65,7 +67,13 @@ describe('Settings Panel', () => { return mountComponent({ ...extraProps, currentSettings: currentSettingsProps }); }; - const findLFSSettingsMessage = () => wrapper.find({ ref: 'git-lfs-settings' }).find('p'); + const findLFSSettingsRow = () => wrapper.find({ ref: 'git-lfs-settings' }); + const findLFSSettingsMessage = () => findLFSSettingsRow().find('p'); + const findLFSFeatureToggle = () => findLFSSettingsRow().find(projectFeatureToggle); + + const findRepositoryFeatureProjectRow = () => wrapper.find({ ref: 'repository-settings' }); + const findRepositoryFeatureSetting = () => + findRepositoryFeatureProjectRow().find(projectFeatureSetting); beforeEach(() => { wrapper = mountComponent(); @@ -154,7 +162,7 @@ describe('Settings Panel', () => { it('should set the repository help text when the visibility level is set to private', () => { wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PRIVATE }); - expect(wrapper.find({ ref: 'repository-settings' }).props().helpText).toEqual( + expect(findRepositoryFeatureProjectRow().props().helpText).toBe( 'View and edit files in this project', ); }); @@ -162,7 +170,7 @@ describe('Settings Panel', () => { it('should set the repository help text with a read access warning when the visibility level is set to non-private', () => { wrapper = overrideCurrentSettings({ visibilityLevel: visibilityOptions.PUBLIC }); - expect(wrapper.find({ ref: 'repository-settings' }).props().helpText).toEqual( + expect(findRepositoryFeatureProjectRow().props().helpText).toBe( 'View and edit files in this project. Non-project members will only have read access', ); }); @@ -176,7 +184,7 @@ describe('Settings Panel', () => { wrapper .find('[name="project[project_feature_attributes][merge_requests_access_level]"]') .props().disabledInput, - ).toEqual(false); + ).toBe(false); }); it('should disable the merge requests access level input when the repository is disabled', () => { @@ -186,7 +194,7 @@ describe('Settings Panel', () => { wrapper .find('[name="project[project_feature_attributes][merge_requests_access_level]"]') .props().disabledInput, - ).toEqual(true); + ).toBe(true); }); }); @@ -197,7 +205,7 @@ describe('Settings Panel', () => { expect( wrapper.find('[name="project[project_feature_attributes][forking_access_level]"]').props() .disabledInput, - ).toEqual(false); + ).toBe(false); }); it('should disable the forking access level input when the repository is disabled', () => { @@ -206,7 +214,7 @@ describe('Settings Panel', () => { expect( wrapper.find('[name="project[project_feature_attributes][forking_access_level]"]').props() .disabledInput, - ).toEqual(true); + ).toBe(true); }); }); @@ -217,7 +225,7 @@ describe('Settings Panel', () => { expect( wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]').props() .disabledInput, - ).toEqual(false); + ).toBe(false); }); it('should disable the builds access level input when the repository is disabled', () => { @@ -226,7 +234,7 @@ describe('Settings Panel', () => { expect( wrapper.find('[name="project[project_feature_attributes][builds_access_level]"]').props() .disabledInput, - ).toEqual(true); + ).toBe(true); }); }); @@ -287,7 +295,7 @@ describe('Settings Panel', () => { expect( wrapper.find('[name="project[container_registry_enabled]"]').props().disabledInput, - ).toEqual(false); + ).toBe(false); }); it('should disable the container registry input when the repository is disabled', () => { @@ -298,7 +306,7 @@ describe('Settings Panel', () => { expect( wrapper.find('[name="project[container_registry_enabled]"]').props().disabledInput, - ).toEqual(true); + ).toBe(true); }); }); @@ -307,7 +315,7 @@ describe('Settings Panel', () => { wrapper.setProps({ lfsAvailable: true }); return wrapper.vm.$nextTick(() => { - expect(wrapper.find({ ref: 'git-lfs-settings' }).exists()).toEqual(true); + expect(findLFSSettingsRow().exists()).toBe(true); }); }); @@ -315,14 +323,12 @@ describe('Settings Panel', () => { wrapper.setProps({ lfsAvailable: false }); return wrapper.vm.$nextTick(() => { - expect(wrapper.find({ ref: 'git-lfs-settings' }).exists()).toEqual(false); + expect(findLFSSettingsRow().exists()).toBe(false); }); }); it('should set the LFS settings help path', () => { - expect(wrapper.find({ ref: 'git-lfs-settings' }).props().helpPath).toBe( - defaultProps.lfsHelpPath, - ); + expect(findLFSSettingsRow().props().helpPath).toBe(defaultProps.lfsHelpPath); }); it('should enable the LFS input when the repository is enabled', () => { @@ -331,7 +337,7 @@ describe('Settings Panel', () => { { lfsAvailable: true }, ); - expect(wrapper.find('[name="project[lfs_enabled]"]').props().disabledInput).toEqual(false); + expect(findLFSFeatureToggle().props().disabledInput).toBe(false); }); it('should disable the LFS input when the repository is disabled', () => { @@ -340,7 +346,27 @@ describe('Settings Panel', () => { { lfsAvailable: true }, ); - expect(wrapper.find('[name="project[lfs_enabled]"]').props().disabledInput).toEqual(true); + expect(findLFSFeatureToggle().props().disabledInput).toBe(true); + }); + + it('should not change lfsEnabled when disabling the repository', async () => { + // mount over shallowMount, because we are aiming to test rendered state of toggle + wrapper = mountComponent({ currentSettings: { lfsEnabled: true } }, mount); + + const repositoryFeatureToggleButton = findRepositoryFeatureSetting().find('button'); + const lfsFeatureToggleButton = findLFSFeatureToggle().find('button'); + const isToggleButtonChecked = toggleButton => toggleButton.classes('is-checked'); + + // assert the initial state + expect(isToggleButtonChecked(lfsFeatureToggleButton)).toBe(true); + expect(isToggleButtonChecked(repositoryFeatureToggleButton)).toBe(true); + + repositoryFeatureToggleButton.trigger('click'); + await wrapper.vm.$nextTick(); + + expect(isToggleButtonChecked(repositoryFeatureToggleButton)).toBe(false); + // LFS toggle should still be checked + expect(isToggleButtonChecked(lfsFeatureToggleButton)).toBe(true); }); describe.each` @@ -364,14 +390,14 @@ describe('Settings Panel', () => { expect(message.text()).toContain( 'LFS objects from this repository are still available to forks', ); - expect(link.text()).toEqual('How do I remove them?'); - expect(link.attributes('href')).toEqual( + expect(link.text()).toBe('How do I remove them?'); + expect(link.attributes('href')).toBe( '/help/topics/git/lfs/index#removing-objects-from-lfs', ); }); } else { it('does not show warning message', () => { - expect(findLFSSettingsMessage().exists()).toEqual(false); + expect(findLFSSettingsMessage().exists()).toBe(false); }); } }, @@ -383,7 +409,7 @@ describe('Settings Panel', () => { wrapper.setProps({ packagesAvailable: true }); return wrapper.vm.$nextTick(() => { - expect(wrapper.find({ ref: 'package-settings' }).exists()).toEqual(true); + expect(wrapper.find({ ref: 'package-settings' }).exists()).toBe(true); }); }); @@ -391,7 +417,7 @@ describe('Settings Panel', () => { wrapper.setProps({ packagesAvailable: false }); return wrapper.vm.$nextTick(() => { - expect(wrapper.find({ ref: 'package-settings' }).exists()).toEqual(false); + expect(wrapper.find({ ref: 'package-settings' }).exists()).toBe(false); }); }); @@ -411,9 +437,7 @@ describe('Settings Panel', () => { { packagesAvailable: true }, ); - expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toEqual( - false, - ); + expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toBe(false); }); it('should disable the packages input when the repository is disabled', () => { @@ -422,9 +446,7 @@ describe('Settings Panel', () => { { packagesAvailable: true }, ); - expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toEqual( - true, - ); + expect(wrapper.find('[name="project[packages_enabled]"]').props().disabledInput).toBe(true); }); }); @@ -503,7 +525,7 @@ describe('Settings Panel', () => { }); it('should contain help text', () => { - expect(wrapper.find({ ref: 'metrics-visibility-settings' }).props().helpText).toEqual( + expect(wrapper.find({ ref: 'metrics-visibility-settings' }).props().helpText).toBe( 'With Metrics Dashboard you can visualize this project performance metrics', ); }); @@ -514,7 +536,7 @@ describe('Settings Panel', () => { const metricsSettingsRow = wrapper.find({ ref: 'metrics-visibility-settings' }); expect(wrapper.vm.metricsOptionsDropdownEnabled).toBe(true); - expect(metricsSettingsRow.find('select').attributes('disabled')).toEqual('disabled'); + expect(metricsSettingsRow.find('select').attributes('disabled')).toBe('disabled'); }); }); }); diff --git a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js index 738498edbd3..589ec0ae047 100644 --- a/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js +++ b/spec/frontend/pages/sessions/new/signin_tabs_memoizer_spec.js @@ -1,8 +1,6 @@ +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; import SigninTabsMemoizer from '~/pages/sessions/new/signin_tabs_memoizer'; -import trackData from '~/pages/sessions/new/index'; -import Tracking from '~/tracking'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; useLocalStorageSpy(); @@ -99,50 +97,6 @@ describe('SigninTabsMemoizer', () => { }); }); - describe('trackData', () => { - beforeEach(() => { - jest.spyOn(Tracking, 'event').mockImplementation(() => {}); - }); - - describe('with tracking data', () => { - beforeEach(() => { - gon.tracking_data = { - category: 'Growth::Acquisition::Experiment::SignUpFlow', - action: 'start', - label: 'uuid', - property: 'control_group', - }; - trackData(); - }); - - it('should track data when the "click" event of the register tab is triggered', () => { - document.querySelector('a[href="#register-pane"]').click(); - - expect(Tracking.event).toHaveBeenCalledWith( - 'Growth::Acquisition::Experiment::SignUpFlow', - 'start', - { - label: 'uuid', - property: 'control_group', - }, - ); - }); - }); - - describe('without tracking data', () => { - beforeEach(() => { - gon.tracking_data = undefined; - trackData(); - }); - - it('should not track data when the "click" event of the register tab is triggered', () => { - document.querySelector('a[href="#register-pane"]').click(); - - expect(Tracking.event).not.toHaveBeenCalled(); - }); - }); - }); - describe('saveData', () => { beforeEach(() => { memo = { diff --git a/spec/frontend/pdf/page_spec.js b/spec/frontend/pdf/page_spec.js index 4e24b0696ec..f9d94781265 100644 --- a/spec/frontend/pdf/page_spec.js +++ b/spec/frontend/pdf/page_spec.js @@ -1,6 +1,6 @@ import Vue from 'vue'; -import PageComponent from '~/pdf/page/index.vue'; import mountComponent from 'helpers/vue_mount_component_helper'; +import PageComponent from '~/pdf/page/index.vue'; jest.mock('pdfjs-dist/webpack', () => { return { default: jest.requireActual('pdfjs-dist/build/pdf') }; diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js index f040dcfdea4..b9dc4c9588c 100644 --- a/spec/frontend/performance_bar/components/detailed_metric_spec.js +++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import { trimText } from 'helpers/text_helper'; import DetailedMetric from '~/performance_bar/components/detailed_metric.vue'; import RequestWarning from '~/performance_bar/components/request_warning.vue'; -import { trimText } from 'helpers/text_helper'; describe('detailedMetric', () => { let wrapper; diff --git a/spec/frontend/persistent_user_callout_spec.js b/spec/frontend/persistent_user_callout_spec.js index 97985ba3a07..578fd8d836a 100644 --- a/spec/frontend/persistent_user_callout_spec.js +++ b/spec/frontend/persistent_user_callout_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import PersistentUserCallout from '~/persistent_user_callout'; -import Flash from '~/flash'; +import { deprecatedCreateFlash as Flash } from '~/flash'; jest.mock('~/flash'); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js new file mode 100644 index 00000000000..d1e6b6b938a --- /dev/null +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -0,0 +1,108 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlNewDropdown, GlNewDropdownItem, GlForm } from '@gitlab/ui'; +import Api from '~/api'; +import PipelineNewForm from '~/pipeline_new/components/pipeline_new_form.vue'; +import { mockRefs, mockParams, mockPostParams, mockProjectId } from '../mock_data'; + +describe('Pipeline New Form', () => { + let wrapper; + + const dummySubmitEvent = { + preventDefault() {}, + }; + + const findForm = () => wrapper.find(GlForm); + const findDropdown = () => wrapper.find(GlNewDropdown); + const findDropdownItems = () => wrapper.findAll(GlNewDropdownItem); + 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 createComponent = (term = '', props = {}, method = shallowMount) => { + wrapper = method(PipelineNewForm, { + propsData: { + projectId: mockProjectId, + pipelinesPath: '', + refs: mockRefs, + defaultBranch: 'master', + settingsLink: '', + ...props, + }, + data() { + return { + searchTerm: term, + }; + }, + }); + }; + + beforeEach(() => { + jest.spyOn(Api, 'createPipeline').mockResolvedValue({ data: { web_url: '/' } }); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('Dropdown with branches and tags', () => { + it('displays dropdown with all branches and tags', () => { + createComponent(); + expect(findDropdownItems().length).toBe(mockRefs.length); + }); + + it('when user enters search term the list is filtered', () => { + createComponent('master'); + + expect(findDropdownItems().length).toBe(1); + expect( + findDropdownItems() + .at(0) + .text(), + ).toBe('master'); + }); + }); + + describe('Form', () => { + beforeEach(() => { + createComponent('', mockParams, mount); + }); + it('displays the correct values for the provided query params', () => { + expect(findDropdown().props('text')).toBe('tag-1'); + + return wrapper.vm.$nextTick().then(() => { + expect(findVariableRows().length).toBe(3); + }); + }); + + it('does not display remove icon for last row', () => { + expect(findRemoveIcons().length).toBe(2); + }); + + it('removes ci variable row on remove icon button click', () => { + findRemoveIcons() + .at(1) + .trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findVariableRows().length).toBe(2); + }); + }); + + it('creates a pipeline on submit', () => { + findForm().vm.$emit('submit', dummySubmitEvent); + + expect(Api.createPipeline).toHaveBeenCalledWith(mockProjectId, mockPostParams); + }); + + it('creates blank variable on input change event', () => { + findKeyInputs() + .at(2) + .trigger('change'); + + return wrapper.vm.$nextTick().then(() => { + expect(findVariableRows().length).toBe(4); + }); + }); + }); +}); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js new file mode 100644 index 00000000000..55ec1fb5afc --- /dev/null +++ b/spec/frontend/pipeline_new/mock_data.js @@ -0,0 +1,21 @@ +export const mockRefs = ['master', 'branch-1', 'tag-1']; + +export const mockParams = { + refParam: 'tag-1', + variableParams: { + test_var: 'test_var_val', + }, + fileParams: { + test_file: 'test_file_val', + }, +}; + +export const mockProjectId = '21'; + +export const mockPostParams = { + ref: 'tag-1', + variables: [ + { key: 'test_var', value: 'test_var_val', variable_type: 'env_var' }, + { key: 'test_file', value: 'test_file_val', variable_type: 'file' }, + ], +}; diff --git a/spec/frontend/pipelines/components/dag/dag_spec.js b/spec/frontend/pipelines/components/dag/dag_spec.js index 7dea6d819b9..989f6c17197 100644 --- a/spec/frontend/pipelines/components/dag/dag_spec.js +++ b/spec/frontend/pipelines/components/dag/dag_spec.js @@ -1,7 +1,4 @@ import { mount, shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; -import waitForPromises from 'helpers/wait_for_promises'; import { GlAlert, GlEmptyState } from '@gitlab/ui'; import Dag from '~/pipelines/components/dag/dag.vue'; import DagGraph from '~/pipelines/components/dag/dag_graph.vue'; @@ -11,13 +8,11 @@ import { ADD_NOTE, REMOVE_NOTE, REPLACE_NOTES, - DEFAULT, PARSE_FAILURE, - LOAD_FAILURE, UNSUPPORTED_DATA, } from '~/pipelines/components/dag//constants'; import { - mockBaseData, + mockParsedGraphQLNodes, tooSmallGraph, unparseableGraph, graphWithoutDependencies, @@ -27,7 +22,6 @@ import { describe('Pipeline DAG graph wrapper', () => { let wrapper; - let mock; const getAlert = () => wrapper.find(GlAlert); const getAllAlerts = () => wrapper.findAll(GlAlert); const getGraph = () => wrapper.find(DagGraph); @@ -35,45 +29,46 @@ describe('Pipeline DAG graph wrapper', () => { const getErrorText = type => wrapper.vm.$options.errorTexts[type]; const getEmptyState = () => wrapper.find(GlEmptyState); - const dataPath = '/root/test/pipelines/90/dag.json'; - - const createComponent = (propsData = {}, method = shallowMount) => { + const createComponent = ({ + graphData = mockParsedGraphQLNodes, + provideOverride = {}, + method = shallowMount, + } = {}) => { if (wrapper?.destroy) { wrapper.destroy(); } wrapper = method(Dag, { - propsData: { + provide: { + pipelineProjectPath: 'root/abc-dag', + pipelineIid: '1', emptySvgPath: '/my-svg', dagDocPath: '/my-doc', - ...propsData, + ...provideOverride, }, data() { return { + graphData, showFailureAlert: false, }; }, }); }; - beforeEach(() => { - mock = new MockAdapter(axios); - }); - afterEach(() => { - mock.restore(); wrapper.destroy(); wrapper = null; }); - describe('when there is no dataUrl', () => { + describe('when a query argument is undefined', () => { beforeEach(() => { - createComponent({ graphUrl: undefined }); + createComponent({ + provideOverride: { pipelineProjectPath: undefined }, + graphData: null, + }); }); - it('shows the DEFAULT alert and not the graph', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(DEFAULT)); + it('does not render the graph', async () => { expect(getGraph().exists()).toBe(false); }); @@ -82,36 +77,12 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('when there is a dataUrl', () => { - describe('but the data fetch fails', () => { + describe('when all query variables are defined', () => { + describe('but the parse fails', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(500); - createComponent({ graphUrl: dataPath }); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); - }); - - it('shows the LOAD_FAILURE alert and not the graph', () => { - expect(getAlert().exists()).toBe(true); - expect(getAlert().text()).toBe(getErrorText(LOAD_FAILURE)); - expect(getGraph().exists()).toBe(false); - }); - - it('does not render the empty state', () => { - expect(getEmptyState().exists()).toBe(false); - }); - }); - - describe('the data fetch succeeds but the parse fails', () => { - beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, unparseableGraph); - createComponent({ graphUrl: dataPath }); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ + graphData: unparseableGraph, + }); }); it('shows the PARSE_FAILURE alert and not the graph', () => { @@ -125,19 +96,12 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('and the data fetch and parse succeeds', () => { + describe('parse succeeds', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, mockBaseData); - createComponent({ graphUrl: dataPath }, mount); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ method: mount }); }); - it('shows the graph and the beta alert', () => { - expect(getAllAlerts().length).toBe(1); - expect(getAlert().text()).toContain('This feature is currently in beta.'); + it('shows the graph', () => { expect(getGraph().exists()).toBe(true); }); @@ -146,14 +110,11 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('the data fetch and parse succeeds, but the resulting graph is too small', () => { + describe('parse succeeds, but the resulting graph is too small', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, tooSmallGraph); - createComponent({ graphUrl: dataPath }); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ + graphData: tooSmallGraph, + }); }); it('shows the UNSUPPORTED_DATA alert and not the graph', () => { @@ -167,19 +128,16 @@ describe('Pipeline DAG graph wrapper', () => { }); }); - describe('the data fetch succeeds but the returned data is empty', () => { + describe('the returned data is empty', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, graphWithoutDependencies); - createComponent({ graphUrl: dataPath }, mount); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent({ + method: mount, + graphData: graphWithoutDependencies, + }); }); it('does not render an error alert or the graph', () => { - expect(getAllAlerts().length).toBe(1); - expect(getAlert().text()).toContain('This feature is currently in beta.'); + expect(getAllAlerts().length).toBe(0); expect(getGraph().exists()).toBe(false); }); @@ -191,12 +149,7 @@ describe('Pipeline DAG graph wrapper', () => { describe('annotations', () => { beforeEach(async () => { - mock.onGet(dataPath).replyOnce(200, mockBaseData); - createComponent({ graphUrl: dataPath }, mount); - - await wrapper.vm.$nextTick(); - - return waitForPromises(); + createComponent(); }); it('toggles on link mouseover and mouseout', async () => { diff --git a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js index a50163411ed..37a7d07485b 100644 --- a/spec/frontend/pipelines/components/dag/drawing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/drawing_utils_spec.js @@ -1,9 +1,9 @@ import { createSankey } from '~/pipelines/components/dag/drawing_utils'; import { parseData } from '~/pipelines/components/dag/parsing_utils'; -import { mockBaseData } from './mock_data'; +import { mockParsedGraphQLNodes } from './mock_data'; describe('DAG visualization drawing utilities', () => { - const parsed = parseData(mockBaseData.stages); + const parsed = parseData(mockParsedGraphQLNodes); const layoutSettings = { width: 200, diff --git a/spec/frontend/pipelines/components/dag/mock_data.js b/spec/frontend/pipelines/components/dag/mock_data.js index 3b39b9cd21c..e7e93804195 100644 --- a/spec/frontend/pipelines/components/dag/mock_data.js +++ b/spec/frontend/pipelines/components/dag/mock_data.js @@ -1,127 +1,56 @@ -/* - It is important that the simple base include parallel jobs - as well as non-parallel jobs with spaces in the name to prevent - us relying on spaces as an indicator. -*/ -export const mockBaseData = { - stages: [ - { - name: 'test', - groups: [ - { - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2', needs: ['frontend fixtures'] }, { name: 'jest 2/2' }], - }, - { - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], - }, - ], - }, - { - name: 'fixtures', - groups: [ - { - name: 'frontend fixtures', - size: 1, - jobs: [{ name: 'frontend fixtures' }], - }, - ], - }, - { - name: 'un-needed', - groups: [ - { - name: 'un-needed', - size: 1, - jobs: [{ name: 'un-needed' }], - }, - ], - }, - ], -}; - -export const tooSmallGraph = { - stages: [ - { - name: 'test', - groups: [ - { - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], - }, - { - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], - }, - ], - }, - { - name: 'fixtures', - groups: [ - { - name: 'frontend fixtures', - size: 1, - jobs: [{ name: 'frontend fixtures' }], - }, - ], - }, - { - name: 'un-needed', - groups: [ - { - name: 'un-needed', - size: 1, - jobs: [{ name: 'un-needed' }], - }, - ], - }, - ], -}; +export const tooSmallGraph = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec', needs: ['frontend fixtures'] }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; -export const graphWithoutDependencies = { - stages: [ - { - name: 'test', - groups: [ - { - name: 'jest', - size: 2, - jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], - }, - { - name: 'rspec', - size: 1, - jobs: [{ name: 'rspec' }], - }, - ], - }, - { - name: 'fixtures', - groups: [ - { - name: 'frontend fixtures', - size: 1, - jobs: [{ name: 'frontend fixtures' }], - }, - ], - }, - { - name: 'un-needed', - groups: [ - { - name: 'un-needed', - size: 1, - jobs: [{ name: 'un-needed' }], - }, - ], - }, - ], -}; +export const graphWithoutDependencies = [ + { + category: 'test', + name: 'jest', + size: 2, + jobs: [{ name: 'jest 1/2' }, { name: 'jest 2/2' }], + }, + { + category: 'test', + name: 'rspec', + size: 1, + jobs: [{ name: 'rspec' }], + }, + { + category: 'fixtures', + name: 'frontend fixtures', + size: 1, + jobs: [{ name: 'frontend fixtures' }], + }, + { + category: 'un-needed', + name: 'un-needed', + size: 1, + jobs: [{ name: 'un-needed' }], + }, +]; export const unparseableGraph = [ { @@ -468,3 +397,264 @@ export const multiNote = { }, }, }; + +/* + It is important that the base include parallel jobs + as well as non-parallel jobs with spaces in the name to prevent + us relying on spaces as an indicator. +*/ + +export const mockParsedGraphQLNodes = [ + { + category: 'build', + name: 'build_a', + size: 1, + jobs: [ + { + name: 'build_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'build', + name: 'build_b', + size: 1, + jobs: [ + { + name: 'build_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_a', + size: 1, + jobs: [ + { + name: 'test_a', + needs: ['build_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_b', + size: 1, + jobs: [ + { + name: 'test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_c', + size: 1, + jobs: [ + { + name: 'test_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'test', + name: 'test_d', + size: 1, + jobs: [ + { + name: 'test_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_a', + size: 1, + jobs: [ + { + name: 'post_test_a', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_b', + size: 1, + jobs: [ + { + name: 'post_test_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'post-test', + name: 'post_test_c', + size: 1, + jobs: [ + { + name: 'post_test_c', + needs: ['test_b', 'test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_a', + size: 1, + jobs: [ + { + name: 'staging_a', + needs: ['post_test_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_b', + size: 1, + jobs: [ + { + name: 'staging_b', + needs: ['post_test_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_c', + size: 1, + jobs: [ + { + name: 'staging_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_d', + size: 1, + jobs: [ + { + name: 'staging_d', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'staging', + name: 'staging_e', + size: 1, + jobs: [ + { + name: 'staging_e', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_a', + size: 1, + jobs: [ + { + name: 'canary_a', + needs: ['staging_b', 'staging_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_b', + size: 1, + jobs: [ + { + name: 'canary_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'canary', + name: 'canary_c', + size: 1, + jobs: [ + { + name: 'canary_c', + needs: ['staging_b'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_a', + size: 1, + jobs: [ + { + name: 'production_a', + needs: ['canary_a'], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_b', + size: 1, + jobs: [ + { + name: 'production_b', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_c', + size: 1, + jobs: [ + { + name: 'production_c', + needs: [], + }, + ], + __typename: 'CiGroup', + }, + { + category: 'production', + name: 'production_d', + size: 1, + jobs: [ + { + name: 'production_d', + needs: ['canary_c'], + }, + ], + __typename: 'CiGroup', + }, +]; diff --git a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js index d9a1296e572..e93fa8e6760 100644 --- a/spec/frontend/pipelines/components/dag/parsing_utils_spec.js +++ b/spec/frontend/pipelines/components/dag/parsing_utils_spec.js @@ -1,5 +1,5 @@ import { - createNodesStructure, + createNodeDict, makeLinksFromNodes, filterByAncestors, parseData, @@ -8,56 +8,17 @@ import { } from '~/pipelines/components/dag/parsing_utils'; import { createSankey } from '~/pipelines/components/dag/drawing_utils'; -import { mockBaseData } from './mock_data'; +import { mockParsedGraphQLNodes } from './mock_data'; describe('DAG visualization parsing utilities', () => { - const { nodes, nodeDict } = createNodesStructure(mockBaseData.stages); - const unfilteredLinks = makeLinksFromNodes(nodes, nodeDict); - const parsed = parseData(mockBaseData.stages); - - const layoutSettings = { - width: 200, - height: 200, - nodeWidth: 10, - nodePadding: 20, - paddingForLabels: 100, - }; - - const sankeyLayout = createSankey(layoutSettings)(parsed); - - describe('createNodesStructure', () => { - const parallelGroupName = 'jest'; - const parallelJobName = 'jest 1/2'; - const singleJobName = 'frontend fixtures'; - - const { name, jobs, size } = mockBaseData.stages[0].groups[0]; - - it('returns the expected node structure', () => { - expect(nodes[0]).toHaveProperty('category', mockBaseData.stages[0].name); - expect(nodes[0]).toHaveProperty('name', name); - expect(nodes[0]).toHaveProperty('jobs', jobs); - expect(nodes[0]).toHaveProperty('size', size); - }); - - it('adds needs to top level of nodeDict entries', () => { - expect(nodeDict[parallelGroupName]).toHaveProperty('needs'); - expect(nodeDict[parallelJobName]).toHaveProperty('needs'); - expect(nodeDict[singleJobName]).toHaveProperty('needs'); - }); - - it('makes entries in nodeDict for jobs and parallel jobs', () => { - const nodeNames = Object.keys(nodeDict); - - expect(nodeNames.includes(parallelGroupName)).toBe(true); - expect(nodeNames.includes(parallelJobName)).toBe(true); - expect(nodeNames.includes(singleJobName)).toBe(true); - }); - }); + const nodeDict = createNodeDict(mockParsedGraphQLNodes); + const unfilteredLinks = makeLinksFromNodes(mockParsedGraphQLNodes, nodeDict); + const parsed = parseData(mockParsedGraphQLNodes); describe('makeLinksFromNodes', () => { it('returns the expected link structure', () => { - expect(unfilteredLinks[0]).toHaveProperty('source', 'frontend fixtures'); - expect(unfilteredLinks[0]).toHaveProperty('target', 'jest'); + expect(unfilteredLinks[0]).toHaveProperty('source', 'build_a'); + expect(unfilteredLinks[0]).toHaveProperty('target', 'test_a'); expect(unfilteredLinks[0]).toHaveProperty('value', 10); }); }); @@ -107,8 +68,22 @@ describe('DAG visualization parsing utilities', () => { describe('removeOrphanNodes', () => { it('removes sankey nodes that have no needs and are not needed', () => { + const layoutSettings = { + width: 200, + height: 200, + nodeWidth: 10, + nodePadding: 20, + paddingForLabels: 100, + }; + + const sankeyLayout = createSankey(layoutSettings)(parsed); const cleanedNodes = removeOrphanNodes(sankeyLayout.nodes); - expect(cleanedNodes).toHaveLength(sankeyLayout.nodes.length - 1); + /* + These lengths are determined by the mock data. + If the data changes, the numbers may also change. + */ + expect(parsed.nodes).toHaveLength(21); + expect(cleanedNodes).toHaveLength(12); }); }); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index add7b56845e..c5b7318d3af 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -1,10 +1,10 @@ -import Api from '~/api'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; +import { GlFilteredSearch } from '@gitlab/ui'; +import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import PipelinesFilteredSearch from '~/pipelines/components/pipelines_list/pipelines_filtered_search.vue'; import { users, mockSearch, branches, tags } from '../mock_data'; -import { GlFilteredSearch } from '@gitlab/ui'; describe('Pipelines filtered search', () => { let wrapper; diff --git a/spec/frontend/pipelines/graph/action_component_spec.js b/spec/frontend/pipelines/graph/action_component_spec.js index 3c5938cfa1f..ab477292bc1 100644 --- a/spec/frontend/pipelines/graph/action_component_spec.js +++ b/spec/frontend/pipelines/graph/action_component_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -7,7 +8,7 @@ import ActionComponent from '~/pipelines/components/graph/action_component.vue'; describe('pipeline graph action component', () => { let wrapper; let mock; - const findButton = () => wrapper.find('button'); + const findButton = () => wrapper.find(GlButton); beforeEach(() => { mock = new MockAdapter(axios); diff --git a/spec/frontend/pipelines/graph/graph_component_spec.js b/spec/frontend/pipelines/graph/graph_component_spec.js index 9731ce3f8a6..1389649abea 100644 --- a/spec/frontend/pipelines/graph/graph_component_spec.js +++ b/spec/frontend/pipelines/graph/graph_component_spec.js @@ -1,5 +1,6 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; +import { setHTMLFixture } from 'helpers/fixtures'; import PipelineStore from '~/pipelines/stores/pipeline_store'; import graphComponent from '~/pipelines/components/graph/graph_component.vue'; import stageColumnComponent from '~/pipelines/components/graph/stage_column_component.vue'; @@ -7,7 +8,6 @@ import linkedPipelinesColumn from '~/pipelines/components/graph/linked_pipelines import graphJSON from './mock_data'; import linkedPipelineJSON from './linked_pipelines_mock_data'; import PipelinesMediator from '~/pipelines/pipeline_details_mediator'; -import { setHTMLFixture } from 'helpers/fixtures'; describe('graph component', () => { const store = new PipelineStore(); diff --git a/spec/frontend/pipelines/graph/linked_pipeline_spec.js b/spec/frontend/pipelines/graph/linked_pipeline_spec.js index 133d5695afb..59121c54ff3 100644 --- a/spec/frontend/pipelines/graph/linked_pipeline_spec.js +++ b/spec/frontend/pipelines/graph/linked_pipeline_spec.js @@ -1,4 +1,5 @@ import { mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import LinkedPipelineComponent from '~/pipelines/components/graph/linked_pipeline.vue'; import CiStatus from '~/vue_shared/components/ci_icon.vue'; @@ -12,7 +13,7 @@ const invalidTriggeredPipelineId = mockPipeline.project.id + 5; describe('Linked pipeline', () => { let wrapper; - const findButton = () => wrapper.find('button'); + const findButton = () => wrapper.find(GlButton); const findPipelineLabel = () => wrapper.find('[data-testid="downstream-pipeline-label"]'); const findLinkedPipeline = () => wrapper.find({ ref: 'linkedPipeline' }); @@ -42,9 +43,7 @@ describe('Linked pipeline', () => { }); it('should render a button', () => { - const linkElement = wrapper.find('.js-linked-pipeline-content'); - - expect(linkElement.exists()).toBe(true); + expect(findButton().exists()).toBe(true); }); it('should render the project name', () => { @@ -62,7 +61,7 @@ describe('Linked pipeline', () => { }); it('should have a ci-status child component', () => { - expect(wrapper.find('.js-linked-pipeline-status').exists()).toBe(true); + expect(wrapper.find(CiStatus).exists()).toBe(true); }); it('should render the pipeline id', () => { @@ -77,15 +76,14 @@ describe('Linked pipeline', () => { }); it('should render the tooltip text as the title attribute', () => { - const tooltipRef = wrapper.find('.js-linked-pipeline-content'); - const titleAttr = tooltipRef.attributes('title'); + const titleAttr = findButton().attributes('title'); expect(titleAttr).toContain(mockPipeline.project.name); expect(titleAttr).toContain(mockPipeline.details.status.label); }); - it('does not render the loading icon when isLoading is false', () => { - expect(wrapper.find('.js-linked-pipeline-loading').exists()).toBe(false); + it('sets the loading prop to false', () => { + expect(findButton().props('loading')).toBe(false); }); it('should display multi-project label when pipeline project id is not the same as triggered pipeline project id', () => { @@ -132,8 +130,8 @@ describe('Linked pipeline', () => { createWrapper(props); }); - it('renders a loading icon', () => { - expect(wrapper.find('.js-linked-pipeline-loading').exists()).toBe(true); + it('sets the loading prop to true', () => { + expect(findButton().props('loading')).toBe(true); }); }); diff --git a/spec/frontend/pipelines/header_component_spec.js b/spec/frontend/pipelines/header_component_spec.js index 1c3a6c545a0..5388d624d3c 100644 --- a/spec/frontend/pipelines/header_component_spec.js +++ b/spec/frontend/pipelines/header_component_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; import HeaderComponent from '~/pipelines/components/header_component.vue'; import CiHeader from '~/vue_shared/components/header_ci_component.vue'; import eventHub from '~/pipelines/event_hub'; -import { GlModal } from '@gitlab/ui'; describe('Pipeline details header', () => { let wrapper; @@ -85,13 +85,13 @@ describe('Pipeline details header', () => { }); it('should call postAction when retry button action is clicked', () => { - wrapper.find('.js-retry-button').vm.$emit('click'); + wrapper.find('[data-testid="retryButton"]').vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'retry'); }); it('should call postAction when cancel button action is clicked', () => { - wrapper.find('.js-btn-cancel-pipeline').vm.$emit('click'); + wrapper.find('[data-testid="cancelPipeline"]').vm.$emit('click'); expect(eventHub.$emit).toHaveBeenCalledWith('headerPostAction', 'cancel'); }); diff --git a/spec/frontend/pipelines/pipeline_details_mediator_spec.js b/spec/frontend/pipelines/pipeline_details_mediator_spec.js index 083e97666ed..d6699a43b54 100644 --- a/spec/frontend/pipelines/pipeline_details_mediator_spec.js +++ b/spec/frontend/pipelines/pipeline_details_mediator_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import PipelineMediator from '~/pipelines/pipeline_details_mediator'; -import waitForPromises from 'helpers/wait_for_promises'; describe('PipelineMdediator', () => { let mediator; diff --git a/spec/frontend/pipelines/pipelines_actions_spec.js b/spec/frontend/pipelines/pipelines_actions_spec.js index aef54d94974..cce4c2dfa7b 100644 --- a/spec/frontend/pipelines/pipelines_actions_spec.js +++ b/spec/frontend/pipelines/pipelines_actions_spec.js @@ -1,11 +1,11 @@ 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 waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import PipelinesActions from '~/pipelines/components/pipelines_list/pipelines_actions.vue'; -import { GlDeprecatedButton } from '@gitlab/ui'; import GlCountdown from '~/vue_shared/components/gl_countdown.vue'; -import waitForPromises from 'helpers/wait_for_promises'; describe('Pipelines Actions dropdown', () => { let wrapper; diff --git a/spec/frontend/pipelines/pipelines_artifacts_spec.js b/spec/frontend/pipelines/pipelines_artifacts_spec.js index 512205c3fc3..83f6cb68eba 100644 --- a/spec/frontend/pipelines/pipelines_artifacts_spec.js +++ b/spec/frontend/pipelines/pipelines_artifacts_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; import { GlLink } from '@gitlab/ui'; +import PipelineArtifacts from '~/pipelines/components/pipelines_list/pipelines_artifacts.vue'; describe('Pipelines Artifacts dropdown', () => { let wrapper; diff --git a/spec/frontend/pipelines/pipelines_spec.js b/spec/frontend/pipelines/pipelines_spec.js index 66446b9aa1d..b0ad6bbd228 100644 --- a/spec/frontend/pipelines/pipelines_spec.js +++ b/spec/frontend/pipelines/pipelines_spec.js @@ -1,16 +1,16 @@ -import Api from '~/api'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; import waitForPromises from 'helpers/wait_for_promises'; +import { GlFilteredSearch } from '@gitlab/ui'; +import Api from '~/api'; +import axios from '~/lib/utils/axios_utils'; import PipelinesComponent from '~/pipelines/components/pipelines_list/pipelines.vue'; import Store from '~/pipelines/stores/pipelines_store'; import { pipelineWithStages, stageReply, users, mockSearch, branches } from './mock_data'; import { RAW_TEXT_WARNING } from '~/pipelines/constants'; -import { GlFilteredSearch } from '@gitlab/ui'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; -jest.mock('~/flash', () => jest.fn()); +jest.mock('~/flash'); describe('Pipelines', () => { const jsonFixtureName = 'pipelines/pipelines.json'; diff --git a/spec/frontend/pipelines/stage_spec.js b/spec/frontend/pipelines/stage_spec.js index 547f8994ca5..e134b81856b 100644 --- a/spec/frontend/pipelines/stage_spec.js +++ b/spec/frontend/pipelines/stage_spec.js @@ -1,10 +1,10 @@ import { mount } 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 StageComponent from '~/pipelines/components/pipelines_list/stage.vue'; import eventHub from '~/pipelines/event_hub'; import { stageReply } from './mock_data'; -import waitForPromises from 'helpers/wait_for_promises'; describe('Pipelines stage component', () => { let wrapper; diff --git a/spec/frontend/pipelines/test_reports/stores/actions_spec.js b/spec/frontend/pipelines/test_reports/stores/actions_spec.js index d4647c55a53..1809f15a6e6 100644 --- a/spec/frontend/pipelines/test_reports/stores/actions_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/actions_spec.js @@ -5,7 +5,7 @@ import * as actions from '~/pipelines/stores/test_reports/actions'; import * as types from '~/pipelines/stores/test_reports/mutation_types'; import { TEST_HOST } from '../../../helpers/test_constants'; import testAction from '../../../helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash.js'); @@ -16,14 +16,13 @@ describe('Actions TestReports Store', () => { const testReports = getJSONFixture('pipelines/test_report.json'); const summary = { total_count: 1 }; - const fullReportEndpoint = `${TEST_HOST}/test_reports.json`; + const suiteEndpoint = `${TEST_HOST}/tests/:suite_name.json`; const summaryEndpoint = `${TEST_HOST}/test_reports/summary.json`; const defaultState = { - fullReportEndpoint, + suiteEndpoint, summaryEndpoint, testReports: {}, selectedSuite: null, - useBuildSummaryReport: false, }; beforeEach(() => { @@ -40,89 +39,63 @@ describe('Actions TestReports Store', () => { mock.onGet(summaryEndpoint).replyOnce(200, summary, {}); }); - describe('when useBuildSummaryReport in state is true', () => { - it('sets testReports and shows tests', done => { - testAction( - actions.fetchSummary, - null, - { ...state, useBuildSummaryReport: true }, - [{ type: types.SET_SUMMARY, payload: summary }], - [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - done, - ); - }); - - it('should create flash on API error', done => { - testAction( - actions.fetchSummary, - null, - { - summaryEndpoint: null, - useBuildSummaryReport: true, - }, - [], - [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, - ); - }); + it('sets testReports and shows tests', done => { + testAction( + actions.fetchSummary, + null, + state, + [{ type: types.SET_SUMMARY, payload: summary }], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + done, + ); }); - describe('when useBuildSummaryReport in state is false', () => { - it('sets testReports and shows tests', done => { - testAction( - actions.fetchSummary, - null, - state, - [{ type: types.SET_SUMMARY, payload: summary }], - [], - done, - ); - }); - - it('should create flash on API error', done => { - testAction( - actions.fetchSummary, - null, - { - summaryEndpoint: null, - }, - [], - [], - () => { - expect(createFlash).toHaveBeenCalled(); - done(); - }, - ); - }); + it('should create flash on API error', done => { + testAction( + actions.fetchSummary, + null, + { summaryEndpoint: null }, + [], + [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], + () => { + expect(createFlash).toHaveBeenCalled(); + done(); + }, + ); }); }); - describe('fetch full report', () => { + describe('fetch test suite', () => { beforeEach(() => { - mock.onGet(fullReportEndpoint).replyOnce(200, testReports, {}); + const buildIds = [1]; + testReports.test_suites[0].build_ids = buildIds; + const endpoint = suiteEndpoint.replace(':suite_name', testReports.test_suites[0].name); + mock + .onGet(endpoint, { params: { build_ids: buildIds } }) + .replyOnce(200, testReports.test_suites[0], {}); }); - it('sets testReports and shows tests', done => { + it('sets test suite and shows tests', done => { + const suite = testReports.test_suites[0]; + const index = 0; + testAction( - actions.fetchFullReport, - null, - state, - [{ type: types.SET_REPORTS, payload: testReports }], + actions.fetchTestSuite, + index, + { ...state, testReports }, + [{ type: types.SET_SUITE, payload: { suite, index } }], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], done, ); }); it('should create flash on API error', done => { + const index = 0; + testAction( - actions.fetchFullReport, - null, - { - fullReportEndpoint: null, - }, + actions.fetchTestSuite, + index, + { ...state, testReports, suiteEndpoint: null }, [], [{ type: 'toggleLoading' }, { type: 'toggleLoading' }], () => { @@ -131,6 +104,15 @@ describe('Actions TestReports Store', () => { }, ); }); + + describe('when we already have the suite data', () => { + it('should not fetch suite', done => { + const index = 0; + testReports.test_suites[0].hasFullSuite = true; + + testAction(actions.fetchTestSuite, index, { ...state, testReports }, [], [], done); + }); + }); }); describe('set selected suite index', () => { diff --git a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js index f4cc5c4bc5d..b935029bc6a 100644 --- a/spec/frontend/pipelines/test_reports/stores/mutations_spec.js +++ b/spec/frontend/pipelines/test_reports/stores/mutations_spec.js @@ -12,20 +12,24 @@ describe('Mutations TestReports Store', () => { testReports: {}, selectedSuite: null, isLoading: false, - hasFullReport: false, }; beforeEach(() => { mockState = { ...defaultState }; }); - describe('set reports', () => { - it('should set testReports', () => { - const expectedState = { ...mockState, testReports }; - mutations[types.SET_REPORTS](mockState, testReports); + describe('set suite', () => { + it('should set the suite at the given index', () => { + mockState.testReports = testReports; + const suite = { name: 'test_suite' }; + const index = 0; + const expectedState = { ...mockState }; + expectedState.testReports.test_suites[index] = { suite, hasFullSuite: true }; + mutations[types.SET_SUITE](mockState, { suite, index }); - expect(mockState.testReports).toEqual(expectedState.testReports); - expect(mockState.hasFullReport).toBe(true); + expect(mockState.testReports.test_suites[index]).toEqual( + expectedState.testReports.test_suites[index], + ); }); }); @@ -40,10 +44,21 @@ describe('Mutations TestReports Store', () => { describe('set summary', () => { it('should set summary', () => { - const summary = { total_count: 1 }; + const summary = { + total: { time: 0, count: 10, success: 1, failed: 2, skipped: 3, error: 4 }, + }; + const expectedSummary = { + ...summary, + total_time: 0, + total_count: 10, + success_count: 1, + failed_count: 2, + skipped_count: 3, + error_count: 4, + }; mutations[types.SET_SUMMARY](mockState, summary); - expect(mockState.testReports).toEqual(summary); + expect(mockState.testReports).toEqual(expectedSummary); }); }); diff --git a/spec/frontend/pipelines/test_reports/test_reports_spec.js b/spec/frontend/pipelines/test_reports/test_reports_spec.js index ef0bcffabe3..a709edf5184 100644 --- a/spec/frontend/pipelines/test_reports/test_reports_spec.js +++ b/spec/frontend/pipelines/test_reports/test_reports_spec.js @@ -22,7 +22,7 @@ describe('Test reports app', () => { const testSummaryTable = () => wrapper.find(TestSummaryTable); const actionSpies = { - fetchFullReport: jest.fn(), + fetchTestSuite: jest.fn(), fetchSummary: jest.fn(), setSelectedSuiteIndex: jest.fn(), removeSelectedSuiteIndex: jest.fn(), @@ -91,28 +91,14 @@ describe('Test reports app', () => { }); describe('when a suite is clicked', () => { - describe('when the full test report has already been received', () => { - beforeEach(() => { - createComponent({ hasFullReport: true }); - testSummaryTable().vm.$emit('row-click', 0); - }); - - it('should only call setSelectedSuiteIndex', () => { - expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); - expect(actionSpies.fetchFullReport).not.toHaveBeenCalled(); - }); + beforeEach(() => { + createComponent({ hasFullReport: true }); + testSummaryTable().vm.$emit('row-click', 0); }); - describe('when the full test report has not been received', () => { - beforeEach(() => { - createComponent({ hasFullReport: false }); - testSummaryTable().vm.$emit('row-click', 0); - }); - - it('should call setSelectedSuiteIndex and fetchFullReport', () => { - expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); - expect(actionSpies.fetchFullReport).toHaveBeenCalled(); - }); + it('should call setSelectedSuiteIndex and fetchTestSuite', () => { + expect(actionSpies.setSelectedSuiteIndex).toHaveBeenCalled(); + expect(actionSpies.fetchTestSuite).toHaveBeenCalled(); }); }); 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 65bffe7039a..3a4aa94571e 100644 --- a/spec/frontend/pipelines/test_reports/test_suite_table_spec.js +++ b/spec/frontend/pipelines/test_reports/test_suite_table_spec.js @@ -23,6 +23,8 @@ 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) => { @@ -61,18 +63,14 @@ describe('Test reports suite table', () => { expect(allCaseRows().length).toBe(testCases.length); }); - it('renders the failed tests first', () => { - const failedCaseNames = testCases - .filter(x => x.status === TestStatus.FAILED) - .map(x => x.name); + 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); - const skippedCaseNames = testCases - .filter(x => x.status === TestStatus.SKIPPED) - .map(x => x.name); - - expect(findCaseRowAtIndex(0).text()).toContain(failedCaseNames[0]); - expect(findCaseRowAtIndex(1).text()).toContain(failedCaseNames[1]); - expect(findCaseRowAtIndex(2).text()).toContain(skippedCaseNames[0]); + expect(allCaseNames()).toEqual(expectedCaseOrder); }); it('renders the correct icon for each status', () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js index 650dd8a1def..2e32d62b4bd 100644 --- a/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_branch_name_token_spec.js @@ -1,6 +1,6 @@ -import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Api from '~/api'; import PipelineBranchNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_branch_name_token.vue'; import { branches, mockBranchesAfterMap } from '../mock_data'; diff --git a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js index 15b283dc2ff..42c9dfc9ff0 100644 --- a/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_tag_name_token_spec.js @@ -1,6 +1,6 @@ -import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Api from '~/api'; import PipelineTagNameToken from '~/pipelines/components/pipelines_list/tokens/pipeline_tag_name_token.vue'; import { tags, mockTagsAfterMap } from '../mock_data'; 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 0b5cf2e202b..c95d2ea1b7b 100644 --- a/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_trigger_author_token_spec.js @@ -1,6 +1,6 @@ -import Api from '~/api'; import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import Api from '~/api'; import PipelineTriggerAuthorToken from '~/pipelines/components/pipelines_list/tokens/pipeline_trigger_author_token.vue'; import { users } from '../mock_data'; diff --git a/spec/frontend/project_find_file_spec.js b/spec/frontend/project_find_file_spec.js index b4c6d202e14..757a02a04a3 100644 --- a/spec/frontend/project_find_file_spec.js +++ b/spec/frontend/project_find_file_spec.js @@ -1,11 +1,13 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { TEST_HOST } from 'helpers/test_constants'; -import sanitize from 'sanitize-html'; +import { sanitize } from 'dompurify'; import ProjectFindFile from '~/project_find_file'; import axios from '~/lib/utils/axios_utils'; -jest.mock('sanitize-html', () => jest.fn(val => val)); +jest.mock('dompurify', () => ({ + sanitize: jest.fn(val => val), +})); const BLOB_URL_TEMPLATE = `${TEST_HOST}/namespace/project/blob/master`; const FILE_FIND_URL = `${TEST_HOST}/namespace/project/files/master?format=json`; diff --git a/spec/frontend/projects/commits/components/author_select_spec.js b/spec/frontend/projects/commits/components/author_select_spec.js index dab91d8b37c..d6fac6f5f79 100644 --- a/spec/frontend/projects/commits/components/author_select_spec.js +++ b/spec/frontend/projects/commits/components/author_select_spec.js @@ -1,14 +1,14 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; -import * as urlUtility from '~/lib/utils/url_utility'; -import AuthorSelect from '~/projects/commits/components/author_select.vue'; -import { createStore } from '~/projects/commits/store'; import { GlNewDropdown, GlNewDropdownHeader, GlSearchBoxByType, GlNewDropdownItem, } 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'; const localVue = createLocalVue(); localVue.use(Vuex); diff --git a/spec/frontend/projects/commits/store/actions_spec.js b/spec/frontend/projects/commits/store/actions_spec.js index 886224252ad..a842aaa2a76 100644 --- a/spec/frontend/projects/commits/store/actions_spec.js +++ b/spec/frontend/projects/commits/store/actions_spec.js @@ -1,10 +1,10 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; -import * as types from '~/projects/commits/store/mutation_types'; import testAction from 'helpers/vuex_action_helper'; +import * as types from '~/projects/commits/store/mutation_types'; import actions from '~/projects/commits/store/actions'; import createState from '~/projects/commits/store/state'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); 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 new file mode 100644 index 00000000000..44220bdef64 --- /dev/null +++ b/spec/frontend/projects/components/__snapshots__/project_delete_button_spec.js.snap @@ -0,0 +1,83 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Project remove modal initialized matches the snapshot 1`] = ` +<form + action="some/path" + method="post" +> + <input + name="_method" + type="hidden" + value="delete" + /> + + <input + name="authenticity_token" + type="hidden" + /> + + <gl-button-stub + category="primary" + icon="" + role="button" + size="medium" + tabindex="0" + variant="danger" + > + Delete project + </gl-button-stub> + + <gl-modal-stub + actioncancel="[object Object]" + actionprimary="[object Object]" + footer-class="gl-bg-gray-10 gl-p-5" + modalclass="" + modalid="fakeUniqueId" + ok-variant="danger" + size="sm" + title-class="gl-text-red-500" + titletag="h4" + > + + <div> + <gl-alert-stub + class="gl-mb-5" + dismisslabel="Dismiss" + primarybuttonlink="" + primarybuttontext="" + secondarybuttonlink="" + secondarybuttontext="" + title="You are about to permanently delete this project" + 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." + /> + </gl-alert-stub> + + <p> + This action cannot be undone. You will lose the project's respository and all conent: issues, merge requests, etc. + </p> + + <p + class="gl-mb-1" + > + Please type the following to confirm: + </p> + + <p> + <code> + foo + </code> + </p> + + <gl-form-input-stub + id="confirm_name_input" + name="confirm_name_input" + type="text" + /> + + </div> + </gl-modal-stub> +</form> +`; diff --git a/spec/frontend/projects/components/__snapshots__/remove_modal_spec.js.snap b/spec/frontend/projects/components/__snapshots__/remove_modal_spec.js.snap deleted file mode 100644 index 4d5b6c56a34..00000000000 --- a/spec/frontend/projects/components/__snapshots__/remove_modal_spec.js.snap +++ /dev/null @@ -1,126 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`Project remove modal initialized matches the snapshot 1`] = ` -<form - action="some/path" - method="post" -> - <input - name="_method" - type="hidden" - value="delete" - /> - - <input - name="authenticity_token" - type="hidden" - /> - - <b-button-stub - class="[object Object]" - event="click" - role="button" - routertag="a" - size="md" - tabindex="0" - tag="button" - type="button" - variant="danger" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - Remove project - </span> - </b-button-stub> - - <b-modal-stub - canceltitle="Cancel" - cancelvariant="secondary" - footerclass="bg-gray-light gl-p-5" - headerclosecontent="×" - headercloselabel="Close" - id="remove-project-modal" - ignoreenforcefocusselector="" - lazy="true" - modalclass="gl-modal," - oktitle="OK" - okvariant="danger" - size="sm" - title="" - titletag="h4" - > - - <div> - <p - class="gl-text-red-500 gl-font-weight-bold" - > - This can lead to data loss. - </p> - - <p - class="gl-mb-0" - > - This action can lead to data loss. To prevent accidental actions we ask you to confirm your intention. - </p> - - <p> - <gl-sprintf-stub - message="Please type %{phrase_code} to proceed or close this modal to cancel." - /> - </p> - - <gl-form-input-stub - id="confirm_name_input" - name="confirm_name_input" - type="text" - /> - </div> - - <template /> - - <template> - Confirmation required - </template> - - <template /> - - <template /> - - <template /> - - <template> - <div - class="gl-w-full gl-display-flex gl-just-content-start gl-m-0" - > - <b-button-stub - class="[object Object]" - disabled="true" - event="click" - routertag="a" - size="md" - tag="button" - type="button" - variant="danger" - > - <!----> - - <!----> - - <span - class="gl-button-text" - > - - Confirm - - </span> - </b-button-stub> - </div> - </template> - </b-modal-stub> -</form> -`; diff --git a/spec/frontend/projects/components/project_delete_button_spec.js b/spec/frontend/projects/components/project_delete_button_spec.js new file mode 100644 index 00000000000..444e465ebaa --- /dev/null +++ b/spec/frontend/projects/components/project_delete_button_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import ProjectDeleteButton from '~/projects/components/project_delete_button.vue'; +import SharedDeleteButton from '~/projects/components/shared/delete_button.vue'; + +jest.mock('lodash/uniqueId', () => () => 'fakeUniqueId'); + +describe('Project remove modal', () => { + let wrapper; + + const findSharedDeleteButton = () => wrapper.find(SharedDeleteButton); + + const defaultProps = { + confirmPhrase: 'foo', + formPath: 'some/path', + }; + + const createComponent = (props = {}) => { + wrapper = shallowMount(ProjectDeleteButton, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + SharedDeleteButton, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('initialized', () => { + beforeEach(() => { + createComponent(); + }); + + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('passes confirmPhrase and formPath props to the shared delete button', () => { + expect(findSharedDeleteButton().props()).toEqual(defaultProps); + }); + }); +}); diff --git a/spec/frontend/projects/components/remove_modal_spec.js b/spec/frontend/projects/components/remove_modal_spec.js deleted file mode 100644 index 339aee65b99..00000000000 --- a/spec/frontend/projects/components/remove_modal_spec.js +++ /dev/null @@ -1,62 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlButton, GlModal } from '@gitlab/ui'; -import ProjectRemoveModal from '~/projects/components/remove_modal.vue'; - -describe('Project remove modal', () => { - let wrapper; - - const findFormElement = () => wrapper.find('form').element; - const findConfirmButton = () => wrapper.find(GlModal).find(GlButton); - - const defaultProps = { - formPath: 'some/path', - confirmPhrase: 'foo', - warningMessage: 'This can lead to data loss.', - }; - - const createComponent = (data = {}) => { - wrapper = shallowMount(ProjectRemoveModal, { - propsData: defaultProps, - data: () => data, - stubs: { - GlButton, - GlModal, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('initialized', () => { - beforeEach(() => { - createComponent(); - }); - - it('matches the snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - }); - - describe('user input matches the confirmPhrase', () => { - beforeEach(() => { - createComponent({ userInput: defaultProps.confirmPhrase }); - }); - - it('the confirm button is not dislabled', () => { - expect(findConfirmButton().attributes('disabled')).toBe(undefined); - }); - - describe('and when the confirmation button is clicked', () => { - beforeEach(() => { - findConfirmButton().vm.$emit('click'); - }); - - it('submits the form element', () => { - expect(findFormElement().submit).toHaveBeenCalled(); - }); - }); - }); -}); 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 new file mode 100644 index 00000000000..a43acc8c002 --- /dev/null +++ b/spec/frontend/projects/components/shared/__snapshots__/delete_button_spec.js.snap @@ -0,0 +1,113 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Project remove modal intialized matches the snapshot 1`] = ` +<form + action="some/path" + method="post" +> + <input + name="_method" + type="hidden" + value="delete" + /> + + <input + name="authenticity_token" + type="hidden" + value="test-csrf-token" + /> + + <gl-button-stub + category="primary" + icon="" + role="button" + size="medium" + tabindex="0" + variant="danger" + > + Delete project + </gl-button-stub> + + <b-modal-stub + canceltitle="Cancel" + cancelvariant="secondary" + footerclass="gl-bg-gray-10 gl-p-5" + headerclosecontent="×" + headercloselabel="Close" + id="delete-project-modal-2" + ignoreenforcefocusselector="" + lazy="true" + modalclass="gl-modal," + oktitle="OK" + okvariant="danger" + size="sm" + title="" + titleclass="gl-text-red-500" + titletag="h4" + > + + <div> + + <p + class="gl-mb-1" + > + Please type the following to confirm: + </p> + + <p> + <code> + foo + </code> + </p> + + <gl-form-input-stub + id="confirm_name_input" + name="confirm_name_input" + type="text" + /> + + </div> + + <template /> + + <template> + Delete project. Are you ABSOLUTELY SURE? + </template> + + <template /> + + <template /> + + <template /> + + <template> + <gl-button-stub + category="primary" + class="js-modal-action-cancel" + icon="" + size="medium" + variant="default" + > + + Cancel, keep project + + </gl-button-stub> + + <!----> + + <gl-button-stub + category="primary" + class="js-modal-action-primary" + disabled="true" + icon="" + size="medium" + variant="danger" + > + + Yes, delete project + + </gl-button-stub> + </template> + </b-modal-stub> +</form> +`; diff --git a/spec/frontend/projects/components/shared/delete_button_spec.js b/spec/frontend/projects/components/shared/delete_button_spec.js new file mode 100644 index 00000000000..a6394a50011 --- /dev/null +++ b/spec/frontend/projects/components/shared/delete_button_spec.js @@ -0,0 +1,83 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlModal } from '@gitlab/ui'; +import SharedDeleteButton from '~/projects/components/shared/delete_button.vue'; + +jest.mock('~/lib/utils/csrf', () => ({ token: 'test-csrf-token' })); + +describe('Project remove modal', () => { + let wrapper; + + const findFormElement = () => wrapper.find('form'); + const findConfirmButton = () => wrapper.find('.js-modal-action-primary'); + const findAuthenticityTokenInput = () => findFormElement().find('input[name=authenticity_token]'); + const findModal = () => wrapper.find(GlModal); + + const defaultProps = { + confirmPhrase: 'foo', + formPath: 'some/path', + }; + + const createComponent = (data = {}) => { + wrapper = shallowMount(SharedDeleteButton, { + propsData: defaultProps, + data: () => data, + stubs: { + GlModal, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('intialized', () => { + beforeEach(() => { + createComponent(); + }); + + it('matches the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('sets a csrf token on the authenticity form input', () => { + expect(findAuthenticityTokenInput().element.value).toEqual('test-csrf-token'); + }); + + it('sets the form action to the provided path', () => { + expect(findFormElement().attributes('action')).toEqual(defaultProps.formPath); + }); + }); + + describe('when the user input does not match the confirmPhrase', () => { + beforeEach(() => { + createComponent({ userInput: 'bar' }); + }); + + it('the confirm button is disabled', () => { + expect(findConfirmButton().attributes('disabled')).toBe('true'); + }); + }); + + describe('when the user input matches the confirmPhrase', () => { + beforeEach(() => { + createComponent({ userInput: defaultProps.confirmPhrase }); + }); + + it('the confirm button is not disabled', () => { + expect(findConfirmButton().attributes('disabled')).toBe(undefined); + }); + }); + + describe('when the modal is confirmed', () => { + beforeEach(() => { + createComponent(); + findModal().vm.$emit('ok'); + }); + + it('submits the form element', () => { + expect(findFormElement().element.submit).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js index cd8b39f0426..42a7aa6bc88 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js +++ b/spec/frontend/projects/experiment_new_project_creation/components/legacy_container_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import LegacyContainer from '~/projects/experiment_new_project_creation/components/legacy_container.vue'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import LegacyContainer from '~/projects/experiment_new_project_creation/components/legacy_container.vue'; describe('Legacy container component', () => { let wrapper; diff --git a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js index acd142fa5ba..cf23ba281f9 100644 --- a/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js +++ b/spec/frontend/projects/experiment_new_project_creation/components/welcome_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; import { mockTracking } from 'helpers/tracking_helper'; +import WelcomePage from '~/projects/experiment_new_project_creation/components/welcome.vue'; describe('Welcome page', () => { let wrapper; diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index 7aafbd33fc8..c32979dcd74 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -1,6 +1,6 @@ import $ from 'jquery'; -import projectNew from '~/projects/project_new'; import { TEST_HOST } from 'jest/helpers/test_constants'; +import projectNew from '~/projects/project_new'; describe('New Project', () => { let $projectImportUrl; diff --git a/spec/frontend/projects/settings/access_dropdown_spec.js b/spec/frontend/projects/settings/access_dropdown_spec.js new file mode 100644 index 00000000000..6d323b0408b --- /dev/null +++ b/spec/frontend/projects/settings/access_dropdown_spec.js @@ -0,0 +1,140 @@ +import $ from 'jquery'; +import '~/gl_dropdown'; +import AccessDropdown from '~/projects/settings/access_dropdown'; +import { LEVEL_TYPES } from '~/projects/settings/constants'; + +describe('AccessDropdown', () => { + const defaultLabel = 'dummy default label'; + let dropdown; + + beforeEach(() => { + setFixtures(` + <div id="dummy-dropdown"> + <span class="dropdown-toggle-text"></span> + </div> + `); + const $dropdown = $('#dummy-dropdown'); + $dropdown.data('defaultLabel', defaultLabel); + const options = { + $dropdown, + accessLevelsData: { + roles: [ + { + id: 42, + text: 'Dummy Role', + }, + ], + }, + }; + dropdown = new AccessDropdown(options); + }); + + describe('toggleLabel', () => { + let $dropdownToggleText; + const dummyItems = [ + { type: LEVEL_TYPES.ROLE, access_level: 42 }, + { type: LEVEL_TYPES.USER }, + { type: LEVEL_TYPES.USER }, + { type: LEVEL_TYPES.GROUP }, + { type: LEVEL_TYPES.GROUP }, + { type: LEVEL_TYPES.GROUP }, + ]; + + beforeEach(() => { + $dropdownToggleText = $('.dropdown-toggle-text'); + }); + + it('displays number of items', () => { + dropdown.setSelectedItems(dummyItems); + $dropdownToggleText.addClass('is-default'); + + const label = dropdown.toggleLabel(); + + expect(label).toBe('1 role, 2 users, 3 groups'); + expect($dropdownToggleText).not.toHaveClass('is-default'); + }); + + describe('without selected items', () => { + beforeEach(() => { + dropdown.setSelectedItems([]); + }); + + it('falls back to default label', () => { + const label = dropdown.toggleLabel(); + + expect(label).toBe(defaultLabel); + expect($dropdownToggleText).toHaveClass('is-default'); + }); + }); + + describe('with only role', () => { + beforeEach(() => { + dropdown.setSelectedItems(dummyItems.filter(item => item.type === LEVEL_TYPES.ROLE)); + $dropdownToggleText.addClass('is-default'); + }); + + it('displays the role name', () => { + const label = dropdown.toggleLabel(); + + expect(label).toBe('Dummy Role'); + expect($dropdownToggleText).not.toHaveClass('is-default'); + }); + }); + + describe('with only users', () => { + beforeEach(() => { + dropdown.setSelectedItems(dummyItems.filter(item => item.type === LEVEL_TYPES.USER)); + $dropdownToggleText.addClass('is-default'); + }); + + it('displays number of users', () => { + const label = dropdown.toggleLabel(); + + expect(label).toBe('2 users'); + expect($dropdownToggleText).not.toHaveClass('is-default'); + }); + }); + + describe('with only groups', () => { + beforeEach(() => { + dropdown.setSelectedItems(dummyItems.filter(item => item.type === LEVEL_TYPES.GROUP)); + $dropdownToggleText.addClass('is-default'); + }); + + it('displays number of groups', () => { + const label = dropdown.toggleLabel(); + + expect(label).toBe('3 groups'); + expect($dropdownToggleText).not.toHaveClass('is-default'); + }); + }); + + describe('with users and groups', () => { + beforeEach(() => { + const selectedTypes = [LEVEL_TYPES.GROUP, LEVEL_TYPES.USER]; + dropdown.setSelectedItems(dummyItems.filter(item => selectedTypes.includes(item.type))); + $dropdownToggleText.addClass('is-default'); + }); + + it('displays number of groups', () => { + const label = dropdown.toggleLabel(); + + expect(label).toBe('2 users, 3 groups'); + expect($dropdownToggleText).not.toHaveClass('is-default'); + }); + }); + }); + + describe('userRowHtml', () => { + it('escapes users name', () => { + const user = { + avatar_url: '', + name: '<img src=x onerror=alert(document.domain)>', + username: 'test', + }; + const template = dropdown.userRowHtml(user); + + expect(template).not.toContain(user.name); + }); + }); +}); diff --git a/spec/frontend/prometheus_alerts/components/reset_key_spec.js b/spec/frontend/prometheus_alerts/components/reset_key_spec.js index df52baafa29..489586a60fe 100644 --- a/spec/frontend/prometheus_alerts/components/reset_key_spec.js +++ b/spec/frontend/prometheus_alerts/components/reset_key_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import ResetKey from '~/prometheus_alerts/components/reset_key.vue'; import { GlModal } from '@gitlab/ui'; import waitForPromises from 'helpers/wait_for_promises'; +import ResetKey from '~/prometheus_alerts/components/reset_key.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import axios from '~/lib/utils/axios_utils'; diff --git a/spec/frontend/ref/components/ref_selector_spec.js b/spec/frontend/ref/components/ref_selector_spec.js index 2688e4b3428..1556f5b19dc 100644 --- a/spec/frontend/ref/components/ref_selector_spec.js +++ b/spec/frontend/ref/components/ref_selector_spec.js @@ -26,12 +26,14 @@ describe('Ref selector component', () => { let tagsApiCallSpy; let commitApiCallSpy; - const createComponent = () => { + const createComponent = (props = {}, attrs = {}) => { wrapper = mount(RefSelector, { propsData: { projectId, value: '', + ...props, }, + attrs, listeners: { // simulate a parent component v-model binding input: selectedRef => { @@ -163,6 +165,52 @@ describe('Ref selector component', () => { }); describe('post-initialization behavior', () => { + describe('when the parent component provides an `id` binding', () => { + const id = 'git-ref'; + + beforeEach(() => { + createComponent({}, { id }); + + return waitForRequests(); + }); + + it('adds the provided ID to the GlNewDropdown instance', () => { + expect(wrapper.attributes().id).toBe(id); + }); + }); + + describe('when a ref is pre-selected', () => { + const preselectedRef = fixtures.branches[0].name; + + beforeEach(() => { + createComponent({ value: preselectedRef }); + + return waitForRequests(); + }); + + it('renders the pre-selected ref name', () => { + expect(findButtonContent().text()).toBe(preselectedRef); + }); + }); + + describe('when the selected ref is updated by the parent component', () => { + const updatedRef = fixtures.branches[0].name; + + beforeEach(() => { + createComponent(); + + return waitForRequests(); + }); + + it('renders the updated ref name', () => { + wrapper.setProps({ value: updatedRef }); + + return localVue.nextTick().then(() => { + expect(findButtonContent().text()).toBe(updatedRef); + }); + }); + }); + describe('when the search query is updated', () => { beforeEach(() => { createComponent(); diff --git a/spec/frontend/registry/explorer/components/details_page/details_row_spec.js b/spec/frontend/registry/explorer/components/details_page/details_row_spec.js deleted file mode 100644 index 95b8e18d677..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/details_row_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { GlIcon } from '@gitlab/ui'; -import component from '~/registry/explorer/components/details_page/details_row.vue'; - -describe('DetailsRow', () => { - let wrapper; - - const findIcon = () => wrapper.find(GlIcon); - const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); - - const mountComponent = () => { - wrapper = shallowMount(component, { - propsData: { - icon: 'clock', - }, - slots: { - default: '<div data-testid="default-slot"></div>', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('contains an icon', () => { - mountComponent(); - expect(findIcon().exists()).toBe(true); - }); - - it('icon has the correct props', () => { - mountComponent(); - expect(findIcon().props()).toMatchObject({ - name: 'clock', - }); - }); - - it('has a default slot', () => { - mountComponent(); - expect(findDefaultSlot().exists()).toBe(true); - }); -}); 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 9e876d6d8a3..a21facefc97 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 @@ -1,11 +1,12 @@ import { shallowMount } from '@vue/test-utils'; import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; 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/explorer/components/details_page/details_row.vue'; +import DetailsRow from '~/registry/shared/components/details_row.vue'; import { REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, @@ -13,7 +14,6 @@ import { NOT_AVAILABLE_TEXT, NOT_AVAILABLE_SIZE, } from '~/registry/explorer/constants/index'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { tagsListResponse } from '../../mock_data'; import { ListItem } from '../../stubs'; 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 a556be12089..b0291de5f3c 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,6 +1,6 @@ import Vuex from 'vuex'; import { mount, createLocalVue } from '@vue/test-utils'; -import { GlDropdown, GlFormGroup, GlFormInputGroup } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlFormGroup, GlFormInputGroup } 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'; @@ -23,7 +23,7 @@ describe('cli_commands', () => { let wrapper; let store; - const findDropdownButton = () => wrapper.find(GlDropdown); + const findDropdownButton = () => wrapper.find(GlDeprecatedDropdown); const findFormGroups = () => wrapper.findAll(GlFormGroup); const mountComponent = () => { diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index 9bc0bae5c23..66e8a4aea0d 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -13,7 +13,7 @@ import { SET_TAGS_LIST_SUCCESS, SET_TAGS_PAGINATION, SET_INITIAL_STATE, -} from '~/registry/explorer/stores/mutation_types/'; +} from '~/registry/explorer/stores/mutation_types'; import { tagsListResponse } from '../mock_data'; import { DeleteModal } from '../stubs'; diff --git a/spec/frontend/registry/explorer/pages/list_spec.js b/spec/frontend/registry/explorer/pages/list_spec.js index 2ece7593b41..b4e46fda2c4 100644 --- a/spec/frontend/registry/explorer/pages/list_spec.js +++ b/spec/frontend/registry/explorer/pages/list_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui'; -import Tracking from '~/tracking'; import waitForPromises from 'helpers/wait_for_promises'; +import Tracking from '~/tracking'; import component from '~/registry/explorer/pages/list.vue'; import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue'; import GroupEmptyState from '~/registry/explorer/components/list_page/group_empty_state.vue'; @@ -14,7 +14,7 @@ import { SET_IMAGES_LIST_SUCCESS, SET_PAGINATION, SET_INITIAL_STATE, -} from '~/registry/explorer/stores/mutation_types/'; +} from '~/registry/explorer/stores/mutation_types'; import { DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE, diff --git a/spec/frontend/registry/explorer/stores/actions_spec.js b/spec/frontend/registry/explorer/stores/actions_spec.js index 15f9db90910..fb93ab06ca8 100644 --- a/spec/frontend/registry/explorer/stores/actions_spec.js +++ b/spec/frontend/registry/explorer/stores/actions_spec.js @@ -1,10 +1,10 @@ -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; -import * as actions from '~/registry/explorer/stores/actions'; -import * as types from '~/registry/explorer/stores/mutation_types'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; import { TEST_HOST } from 'helpers/test_constants'; +import axios from '~/lib/utils/axios_utils'; +import * as actions from '~/registry/explorer/stores/actions'; +import * as types from '~/registry/explorer/stores/mutation_types'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { reposServerResponse, registryServerResponse } from '../mock_data'; jest.mock('~/flash.js'); diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 9b9ca92270c..6f9518808db 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -1,4 +1,5 @@ import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; import Tracking from '~/tracking'; import component from '~/registry/settings/components/settings_form.vue'; import expirationPolicyFields from '~/registry/shared/components/expiration_policy_fields.vue'; @@ -7,7 +8,6 @@ import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/registry/shared/constants'; -import waitForPromises from 'helpers/wait_for_promises'; import { stringifiedFormOptions } from '../../shared/mock_data'; describe('Settings Form', () => { diff --git a/spec/frontend/registry/settings/store/actions_spec.js b/spec/frontend/registry/settings/store/actions_spec.js index f92d10d087f..51b89f96ef2 100644 --- a/spec/frontend/registry/settings/store/actions_spec.js +++ b/spec/frontend/registry/settings/store/actions_spec.js @@ -1,5 +1,5 @@ -import Api from '~/api'; import testAction from 'helpers/vuex_action_helper'; +import Api from '~/api'; import * as actions from '~/registry/settings/store/actions'; import * as types from '~/registry/settings/store/mutation_types'; diff --git a/spec/frontend/registry/shared/components/details_row_spec.js b/spec/frontend/registry/shared/components/details_row_spec.js new file mode 100644 index 00000000000..5ae4e0ab37f --- /dev/null +++ b/spec/frontend/registry/shared/components/details_row_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import component from '~/registry/shared/components/details_row.vue'; + +describe('DetailsRow', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + const mountComponent = props => { + wrapper = shallowMount(component, { + propsData: { + icon: 'clock', + ...props, + }, + slots: { + default: '<div data-testid="default-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('has a default slot', () => { + mountComponent(); + expect(findDefaultSlot().exists()).toBe(true); + }); + + describe('icon prop', () => { + it('contains an icon', () => { + mountComponent(); + expect(findIcon().exists()).toBe(true); + }); + + it('icon has the correct props', () => { + mountComponent(); + expect(findIcon().props()).toMatchObject({ + name: 'clock', + }); + }); + }); + + describe('padding prop', () => { + it('padding has a default', () => { + mountComponent(); + expect(wrapper.classes('gl-py-2')).toBe(true); + }); + + it('is reflected in the template', () => { + mountComponent({ padding: 'gl-py-4' }); + expect(wrapper.classes('gl-py-4')).toBe(true); + }); + }); + + describe('dashed prop', () => { + const borderClasses = ['gl-border-b-solid', 'gl-border-gray-100', 'gl-border-b-1']; + it('by default component has no border', () => { + mountComponent(); + expect(wrapper.classes).not.toEqual(expect.arrayContaining(borderClasses)); + }); + + it('has a border when dashed is true', () => { + mountComponent({ dashed: true }); + expect(wrapper.classes()).toEqual(expect.arrayContaining(borderClasses)); + }); + }); +}); diff --git a/spec/frontend/related_merge_requests/store/actions_spec.js b/spec/frontend/related_merge_requests/store/actions_spec.js index 26c5977cb5f..fa031a91c83 100644 --- a/spec/frontend/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/related_merge_requests/store/actions_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; -import createFlash from '~/flash'; import testAction from 'helpers/vuex_action_helper'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as types from '~/related_merge_requests/store/mutation_types'; import * as actions from '~/related_merge_requests/store/actions'; diff --git a/spec/frontend/releases/components/app_edit_spec.js b/spec/frontend/releases/components/app_edit_new_spec.js index 4450b047acd..e9727801c1a 100644 --- a/spec/frontend/releases/components/app_edit_spec.js +++ b/spec/frontend/releases/components/app_edit_new_spec.js @@ -1,15 +1,15 @@ import Vuex from 'vuex'; import { mount } from '@vue/test-utils'; -import ReleaseEditApp from '~/releases/components/app_edit.vue'; +import { merge } from 'lodash'; +import axios from 'axios'; +import MockAdapter from 'axios-mock-adapter'; +import ReleaseEditNewApp from '~/releases/components/app_edit_new.vue'; import { release as originalRelease, milestones as originalMilestones } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; import { BACK_URL_PARAM } from '~/releases/constants'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; -import { merge } from 'lodash'; -import axios from 'axios'; -import MockAdapter from 'axios-mock-adapter'; -describe('Release edit component', () => { +describe('Release edit/new component', () => { let wrapper; let release; let actions; @@ -27,13 +27,14 @@ describe('Release edit component', () => { }; actions = { - fetchRelease: jest.fn(), - updateRelease: jest.fn(), + initializeRelease: jest.fn(), + saveRelease: jest.fn(), addEmptyAssetLink: jest.fn(), }; getters = { isValid: () => true, + isExistingRelease: () => true, validationErrors: () => ({ assets: { links: [], @@ -57,12 +58,14 @@ describe('Release edit component', () => { ), ); - wrapper = mount(ReleaseEditApp, { + wrapper = mount(ReleaseEditNewApp, { store, provide: { glFeatures: featureFlags, }, }); + + wrapper.element.querySelectorAll('input').forEach(input => jest.spyOn(input, 'focus')); }; beforeEach(() => { @@ -80,14 +83,23 @@ describe('Release edit component', () => { }); const findSubmitButton = () => wrapper.find('button[type=submit]'); + const findForm = () => wrapper.find('form'); describe(`basic functionality tests: all tests unrelated to the "${BACK_URL_PARAM}" parameter`, () => { - beforeEach(() => { - factory(); + beforeEach(factory); + + it('calls initializeRelease when the component is created', () => { + expect(actions.initializeRelease).toHaveBeenCalledTimes(1); }); - it('calls fetchRelease when the component is created', () => { - expect(actions.fetchRelease).toHaveBeenCalledTimes(1); + it('focuses the first non-disabled input element once the page is shown', () => { + const firstEnabledInput = wrapper.element.querySelector('input:enabled'); + const allInputs = wrapper.element.querySelectorAll('input'); + + allInputs.forEach(input => { + const expectedFocusCalls = input === firstEnabledInput ? 1 : 0; + expect(input.focus).toHaveBeenCalledTimes(expectedFocusCalls); + }); }); it('renders the description text at the top of the page', () => { @@ -96,28 +108,6 @@ describe('Release edit component', () => { ); }); - it('renders the correct tag name in the "Tag name" field', () => { - expect(wrapper.find('#git-ref').element.value).toBe(release.tagName); - }); - - it('renders the correct help text under the "Tag name" field', () => { - const helperText = wrapper.find('#tag-name-help'); - const helperTextLink = helperText.find('a'); - const helperTextLinkAttrs = helperTextLink.attributes(); - - expect(helperText.text()).toBe( - 'Changing a Release tag is only supported via Releases API. More information', - ); - expect(helperTextLink.text()).toBe('More information'); - expect(helperTextLinkAttrs).toEqual( - expect.objectContaining({ - href: state.updateReleaseApiDocsPath, - rel: 'noopener noreferrer', - target: '_blank', - }), - ); - }); - it('renders the correct release title in the "Release title" field', () => { expect(wrapper.find('#release-title').element.value).toBe(release.name); }); @@ -130,16 +120,15 @@ describe('Release edit component', () => { expect(findSubmitButton().attributes('type')).toBe('submit'); }); - it('calls updateRelease when the form is submitted', () => { - wrapper.find('form').trigger('submit'); - expect(actions.updateRelease).toHaveBeenCalledTimes(1); + it('calls saveRelease when the form is submitted', () => { + findForm().trigger('submit'); + + expect(actions.saveRelease).toHaveBeenCalledTimes(1); }); }); describe(`when the URL does not contain a "${BACK_URL_PARAM}" parameter`, () => { - beforeEach(() => { - factory(); - }); + beforeEach(factory); it(`renders a "Cancel" button with an href pointing to "${BACK_URL_PARAM}"`, () => { const cancelButton = wrapper.find('.js-cancel-button'); @@ -164,6 +153,34 @@ describe('Release edit component', () => { }); }); + describe('when creating a new release', () => { + beforeEach(() => { + factory({ + store: { + modules: { + detail: { + getters: { + isExistingRelease: () => false, + }, + }, + }, + }, + }); + }); + + it('renders the submit button with the text "Create release"', () => { + expect(findSubmitButton().text()).toBe('Create release'); + }); + }); + + describe('when editing an existing release', () => { + beforeEach(factory); + + it('renders the submit button with the text "Save changes"', () => { + expect(findSubmitButton().text()).toBe('Save changes'); + }); + }); + describe('asset links form', () => { const findAssetLinksForm = () => wrapper.find(AssetLinksForm); @@ -227,6 +244,12 @@ describe('Release edit component', () => { it('renders the submit button as disabled', () => { expect(findSubmitButton().attributes('disabled')).toBe('disabled'); }); + + it('does not allow the form to be submitted', () => { + findForm().trigger('submit'); + + expect(actions.saveRelease).not.toHaveBeenCalled(); + }); }); }); }); diff --git a/spec/frontend/releases/components/app_index_spec.js b/spec/frontend/releases/components/app_index_spec.js index 91beb5b1418..8eafe07cb2f 100644 --- a/spec/frontend/releases/components/app_index_spec.js +++ b/spec/frontend/releases/components/app_index_spec.js @@ -1,6 +1,7 @@ import { range as rge } from 'lodash'; import Vue from 'vue'; import { mountComponentWithStore } from 'helpers/vue_mount_component_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import app from '~/releases/components/app_index.vue'; import createStore from '~/releases/stores'; import listModule from '~/releases/stores/modules/list'; @@ -13,7 +14,6 @@ import { releases, } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import waitForPromises from 'helpers/wait_for_promises'; describe('Releases App ', () => { const Component = Vue.extend(app); diff --git a/spec/frontend/releases/components/app_new_spec.js b/spec/frontend/releases/components/app_new_spec.js deleted file mode 100644 index 0d5664766e5..00000000000 --- a/spec/frontend/releases/components/app_new_spec.js +++ /dev/null @@ -1,26 +0,0 @@ -import Vue from 'vue'; -import Vuex from 'vuex'; -import { mount } from '@vue/test-utils'; -import ReleaseNewApp from '~/releases/components/app_new.vue'; - -Vue.use(Vuex); - -describe('Release new component', () => { - let wrapper; - - const factory = () => { - const store = new Vuex.Store(); - wrapper = mount(ReleaseNewApp, { store }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it('renders the app', () => { - factory(); - - expect(wrapper.exists()).toBe(true); - }); -}); diff --git a/spec/frontend/releases/components/app_show_spec.js b/spec/frontend/releases/components/app_show_spec.js index 3dc9964c25c..e757fe98661 100644 --- a/spec/frontend/releases/components/app_show_spec.js +++ b/spec/frontend/releases/components/app_show_spec.js @@ -1,8 +1,8 @@ import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; +import { GlSkeletonLoading } from '@gitlab/ui'; import ReleaseShowApp from '~/releases/components/app_show.vue'; import { release as originalRelease } from '../mock_data'; -import { GlSkeletonLoading } from '@gitlab/ui'; import ReleaseBlock from '~/releases/components/release_block.vue'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; diff --git a/spec/frontend/releases/components/asset_links_form_spec.js b/spec/frontend/releases/components/asset_links_form_spec.js index e1f8592270e..727d593d851 100644 --- a/spec/frontend/releases/components/asset_links_form_spec.js +++ b/spec/frontend/releases/components/asset_links_form_spec.js @@ -3,6 +3,7 @@ import { mount, createLocalVue } from '@vue/test-utils'; import AssetLinksForm from '~/releases/components/asset_links_form.vue'; import { release as originalRelease } from '../mock_data'; import * as commonUtils from '~/lib/utils/common_utils'; +import { ENTER_KEY } from '~/lib/utils/keys'; import { ASSET_LINK_TYPE, DEFAULT_ASSET_LINK_TYPE } from '~/releases/constants'; const localVue = createLocalVue(); @@ -91,42 +92,128 @@ describe('Release edit component', () => { expect(actions.removeAssetLink).toHaveBeenCalledTimes(1); }); - it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => { - const linkIdToUpdate = release.assets.links[0].id; - const newUrl = 'updated url'; + describe('URL input field', () => { + let input; + let linkIdToUpdate; + let newUrl; - expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled(); + beforeEach(() => { + input = wrapper.find({ ref: 'urlInput' }).element; + linkIdToUpdate = release.assets.links[0].id; + newUrl = 'updated url'; + }); - wrapper.find({ ref: 'urlInput' }).vm.$emit('change', newUrl); + const expectStoreMethodNotToBeCalled = () => { + expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled(); + }; - expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newUrl, - }, - undefined, - ); + const dispatchKeydowEvent = eventParams => { + const event = new KeyboardEvent('keydown', eventParams); + + input.dispatchEvent(event); + }; + + const expectStoreMethodToBeCalled = () => { + expect(actions.updateAssetLinkUrl).toHaveBeenCalledTimes(1); + expect(actions.updateAssetLinkUrl).toHaveBeenCalledWith( + expect.anything(), + { + linkIdToUpdate, + newUrl, + }, + undefined, + ); + }; + + it('calls the "updateAssetLinkUrl" store method when text is entered into the "URL" input field', () => { + expectStoreMethodNotToBeCalled(); + + wrapper.find({ ref: 'urlInput' }).vm.$emit('change', newUrl); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkUrl" store method when Ctrl+Enter is pressed inside the "URL" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newUrl; + + dispatchKeydowEvent({ key: ENTER_KEY, ctrlKey: true }); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkUrl" store method when Cmd+Enter is pressed inside the "URL" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newUrl; + + dispatchKeydowEvent({ key: ENTER_KEY, metaKey: true }); + + expectStoreMethodToBeCalled(); + }); }); - it('calls the "updateAssetLinkName" store method when text is entered into the "Link title" input field', () => { - const linkIdToUpdate = release.assets.links[0].id; - const newName = 'updated name'; + describe('Link title field', () => { + let input; + let linkIdToUpdate; + let newName; - expect(actions.updateAssetLinkName).not.toHaveBeenCalled(); + beforeEach(() => { + input = wrapper.find({ ref: 'nameInput' }).element; + linkIdToUpdate = release.assets.links[0].id; + newName = 'updated name'; + }); - wrapper.find({ ref: 'nameInput' }).vm.$emit('change', newName); + const expectStoreMethodNotToBeCalled = () => { + expect(actions.updateAssetLinkUrl).not.toHaveBeenCalled(); + }; - expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1); - expect(actions.updateAssetLinkName).toHaveBeenCalledWith( - expect.anything(), - { - linkIdToUpdate, - newName, - }, - undefined, - ); + const dispatchKeydowEvent = eventParams => { + const event = new KeyboardEvent('keydown', eventParams); + + input.dispatchEvent(event); + }; + + const expectStoreMethodToBeCalled = () => { + expect(actions.updateAssetLinkName).toHaveBeenCalledTimes(1); + expect(actions.updateAssetLinkName).toHaveBeenCalledWith( + expect.anything(), + { + linkIdToUpdate, + newName, + }, + undefined, + ); + }; + + it('calls the "updateAssetLinkName" store method when text is entered into the "Link title" input field', () => { + expectStoreMethodNotToBeCalled(); + + wrapper.find({ ref: 'nameInput' }).vm.$emit('change', newName); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkName" store method when Ctrl+Enter is pressed inside the "Link title" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newName; + + dispatchKeydowEvent({ key: ENTER_KEY, ctrlKey: true }); + + expectStoreMethodToBeCalled(); + }); + + it('calls the "updateAssetLinkName" store method when Cmd+Enter is pressed inside the "Link title" input field', () => { + expectStoreMethodNotToBeCalled(); + + input.value = newName; + + dispatchKeydowEvent({ key: ENTER_KEY, metaKey: true }); + + expectStoreMethodToBeCalled(); + }); }); it('calls the "updateAssetLinkType" store method when an option is selected from the "Type" dropdown', () => { diff --git a/spec/frontend/releases/components/release_block_assets_spec.js b/spec/frontend/releases/components/release_block_assets_spec.js index a85532a8118..5e84290716c 100644 --- a/spec/frontend/releases/components/release_block_assets_spec.js +++ b/spec/frontend/releases/components/release_block_assets_spec.js @@ -1,10 +1,10 @@ import { mount } from '@vue/test-utils'; import { GlCollapse } from '@gitlab/ui'; +import { trimText } from 'helpers/text_helper'; +import { cloneDeep } from 'lodash'; import ReleaseBlockAssets from '~/releases/components/release_block_assets.vue'; import { ASSET_LINK_TYPE } from '~/releases/constants'; -import { trimText } from 'helpers/text_helper'; import { assets } from '../mock_data'; -import { cloneDeep } from 'lodash'; describe('Release block assets', () => { let wrapper; diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index b91cfb82b65..c066bfbf020 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -1,11 +1,11 @@ import { mount } from '@vue/test-utils'; import { GlLink } 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'; -import { cloneDeep } from 'lodash'; const mockFutureDate = new Date(9999, 0, 0).toISOString(); let mockIsFutureRelease = false; diff --git a/spec/frontend/releases/components/release_block_metadata_spec.js b/spec/frontend/releases/components/release_block_metadata_spec.js index cbe478bfa1f..6f184e45600 100644 --- a/spec/frontend/releases/components/release_block_metadata_spec.js +++ b/spec/frontend/releases/components/release_block_metadata_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; +import { cloneDeep } from 'lodash'; import ReleaseBlockMetadata from '~/releases/components/release_block_metadata.vue'; import { release as originalRelease } from '../mock_data'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; -import { cloneDeep } from 'lodash'; const mockFutureDate = new Date(9999, 0, 0).toISOString(); let mockIsFutureRelease = false; diff --git a/spec/frontend/releases/components/tag_field_exsting_spec.js b/spec/frontend/releases/components/tag_field_exsting_spec.js new file mode 100644 index 00000000000..0a04f68bd67 --- /dev/null +++ b/spec/frontend/releases/components/tag_field_exsting_spec.js @@ -0,0 +1,78 @@ +import { GlFormInput } from '@gitlab/ui'; +import { shallowMount, mount } 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'; + +const TEST_TAG_NAME = 'test-tag-name'; +const TEST_DOCS_PATH = '/help/test/docs/path'; + +describe('releases/components/tag_field_existing', () => { + let store; + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + wrapper = mountFn(TagFieldExisting, { + store, + }); + }; + + const findInput = () => wrapper.find(GlFormInput); + const findHelp = () => wrapper.find('[data-testid="tag-name-help"]'); + const findHelpLink = () => { + const link = findHelp().find('a'); + + return { + text: link.text(), + href: link.attributes('href'), + target: link.attributes('target'), + }; + }; + + beforeEach(() => { + store = createStore({ + modules: { + detail: createDetailModule({ + updateReleaseApiDocsPath: TEST_DOCS_PATH, + tagName: TEST_TAG_NAME, + }), + }, + }); + + store.state.detail.release = { + tagName: TEST_TAG_NAME, + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('default', () => { + it('shows the tag name', () => { + createComponent(); + + expect(findInput().attributes()).toMatchObject({ + disabled: '', + value: TEST_TAG_NAME, + }); + }); + + it('shows help', () => { + createComponent(mount); + + expect(findHelp().text()).toMatchInterpolatedText( + 'Changing a Release tag is only supported via Releases API. More information', + ); + + const helpLink = findHelpLink(); + + expect(helpLink).toEqual({ + text: 'More information', + href: TEST_DOCS_PATH, + target: '_blank', + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_new_spec.js b/spec/frontend/releases/components/tag_field_new_spec.js new file mode 100644 index 00000000000..b6ebc496f33 --- /dev/null +++ b/spec/frontend/releases/components/tag_field_new_spec.js @@ -0,0 +1,144 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlFormInput } from '@gitlab/ui'; +import TagFieldNew from '~/releases/components/tag_field_new.vue'; +import createStore from '~/releases/stores'; +import createDetailModule from '~/releases/stores/modules/detail'; +import RefSelector from '~/ref/components/ref_selector.vue'; + +const TEST_TAG_NAME = 'test-tag-name'; +const TEST_PROJECT_ID = '1234'; +const TEST_CREATE_FROM = 'test-create-from'; + +describe('releases/components/tag_field_new', () => { + let store; + let wrapper; + + const createComponent = (mountFn = shallowMount) => { + wrapper = mountFn(TagFieldNew, { + store, + stubs: { + RefSelector: true, + }, + }); + }; + + beforeEach(() => { + store = createStore({ + modules: { + detail: createDetailModule({ + projectId: TEST_PROJECT_ID, + }), + }, + }); + + store.state.detail.createFrom = TEST_CREATE_FROM; + + store.state.detail.release = { + tagName: TEST_TAG_NAME, + assets: { + links: [], + }, + }; + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTagNameFormGroup = () => wrapper.find('[data-testid="tag-name-field"]'); + const findTagNameGlInput = () => findTagNameFormGroup().find(GlFormInput); + const findTagNameInput = () => findTagNameFormGroup().find('input'); + + const findCreateFromFormGroup = () => wrapper.find('[data-testid="create-from-field"]'); + const findCreateFromDropdown = () => findCreateFromFormGroup().find(RefSelector); + + describe('"Tag name" field', () => { + describe('rendering and behavior', () => { + beforeEach(() => createComponent()); + + it('renders a label', () => { + expect(findTagNameFormGroup().attributes().label).toBe('Tag name'); + }); + + describe('when the user updates the field', () => { + it("updates the store's release.tagName property", () => { + const updatedTagName = 'updated-tag-name'; + findTagNameGlInput().vm.$emit('input', updatedTagName); + + return wrapper.vm.$nextTick().then(() => { + expect(store.state.detail.release.tagName).toBe(updatedTagName); + }); + }); + }); + }); + + describe('validation', () => { + beforeEach(() => { + createComponent(mount); + }); + + /** + * Utility function to test the visibility of the validation message + * @param {'shown' | 'hidden'} state The expected state of the validation message. + * Should be passed either 'shown' or 'hidden' + */ + const expectValidationMessageToBe = state => { + return wrapper.vm.$nextTick().then(() => { + expect(findTagNameFormGroup().element).toHaveClass( + state === 'shown' ? 'is-invalid' : 'is-valid', + ); + expect(findTagNameFormGroup().element).not.toHaveClass( + state === 'shown' ? 'is-valid' : 'is-invalid', + ); + }); + }; + + describe('when the user has not yet interacted with the component', () => { + it('does not display a validation error', () => { + findTagNameInput().setValue(''); + + return expectValidationMessageToBe('hidden'); + }); + }); + + describe('when the user has interacted with the component and the value is not empty', () => { + it('does not display validation error', () => { + findTagNameInput().trigger('blur'); + + return expectValidationMessageToBe('hidden'); + }); + }); + + describe('when the user has interacted with the component and the value is empty', () => { + it('displays a validation error', () => { + const tagNameInput = findTagNameInput(); + + tagNameInput.setValue(''); + tagNameInput.trigger('blur'); + + return expectValidationMessageToBe('shown'); + }); + }); + }); + }); + + describe('"Create from" field', () => { + beforeEach(() => createComponent()); + + it('renders a label', () => { + expect(findCreateFromFormGroup().attributes().label).toBe('Create from'); + }); + + describe('when the user selects a git ref', () => { + it("updates the store's createFrom property", () => { + const updatedCreateFrom = 'update-create-from'; + findCreateFromDropdown().vm.$emit('input', updatedCreateFrom); + + return wrapper.vm.$nextTick().then(() => { + expect(store.state.detail.createFrom).toBe(updatedCreateFrom); + }); + }); + }); + }); +}); diff --git a/spec/frontend/releases/components/tag_field_spec.js b/spec/frontend/releases/components/tag_field_spec.js new file mode 100644 index 00000000000..c7909a2369b --- /dev/null +++ b/spec/frontend/releases/components/tag_field_spec.js @@ -0,0 +1,59 @@ +import { shallowMount } from '@vue/test-utils'; +import TagField from '~/releases/components/tag_field.vue'; +import TagFieldNew from '~/releases/components/tag_field_new.vue'; +import TagFieldExisting from '~/releases/components/tag_field_existing.vue'; +import createStore from '~/releases/stores'; +import createDetailModule from '~/releases/stores/modules/detail'; + +describe('releases/components/tag_field', () => { + let store; + let wrapper; + + const createComponent = ({ tagName }) => { + store = createStore({ + modules: { + detail: createDetailModule({}), + }, + }); + + store.state.detail.tagName = tagName; + + wrapper = shallowMount(TagField, { store }); + }; + + const findTagFieldNew = () => wrapper.find(TagFieldNew); + const findTagFieldExisting = () => wrapper.find(TagFieldExisting); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('when an existing release is being edited', () => { + beforeEach(() => { + createComponent({ tagName: 'v1.0' }); + }); + + it('renders the TagFieldExisting component', () => { + expect(findTagFieldExisting().exists()).toBe(true); + }); + + it('does not render the TagFieldNew component', () => { + expect(findTagFieldNew().exists()).toBe(false); + }); + }); + + describe('when a new release is being created', () => { + beforeEach(() => { + createComponent({ tagName: null }); + }); + + it('renders the TagFieldNew component', () => { + expect(findTagFieldNew().exists()).toBe(true); + }); + + it('does not render the TagFieldExisting component', () => { + expect(findTagFieldExisting().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/releases/stores/modules/detail/actions_spec.js b/spec/frontend/releases/stores/modules/detail/actions_spec.js index 345be2acc71..1b2a705e8f4 100644 --- a/spec/frontend/releases/stores/modules/detail/actions_spec.js +++ b/spec/frontend/releases/stores/modules/detail/actions_spec.js @@ -1,18 +1,20 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import { cloneDeep, merge } from 'lodash'; +import { cloneDeep } from 'lodash'; import * as actions from '~/releases/stores/modules/detail/actions'; import * as types from '~/releases/stores/modules/detail/mutation_types'; import { release as originalRelease } from '../../../mock_data'; import createState from '~/releases/stores/modules/detail/state'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { redirectTo } from '~/lib/utils/url_utility'; import api from '~/api'; +import httpStatus from '~/lib/utils/http_status'; import { ASSET_LINK_TYPE } from '~/releases/constants'; +import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; -jest.mock('~/flash', () => jest.fn()); +jest.mock('~/flash'); jest.mock('~/lib/utils/url_utility', () => ({ redirectTo: jest.fn(), @@ -25,15 +27,26 @@ describe('Release detail actions', () => { let mock; let error; + const setupState = (updates = {}) => { + const getters = { + isExistingRelease: true, + }; + + state = { + ...createState({ + projectId: '18', + tagName: release.tag_name, + releasesPagePath: 'path/to/releases/page', + markdownDocsPath: 'path/to/markdown/docs', + markdownPreviewPath: 'path/to/markdown/preview', + updateReleaseApiDocsPath: 'path/to/api/docs', + }), + ...getters, + ...updates, + }; + }; + beforeEach(() => { - state = createState({ - projectId: '18', - tagName: 'v1.3', - releasesPagePath: 'path/to/releases/page', - markdownDocsPath: 'path/to/markdown/docs', - markdownPreviewPath: 'path/to/markdown/preview', - updateReleaseApiDocsPath: 'path/to/api/docs', - }); release = cloneDeep(originalRelease); mock = new MockAdapter(axios); gon.api_version = 'v4'; @@ -45,284 +58,424 @@ describe('Release detail actions', () => { mock.restore(); }); - describe('requestRelease', () => { - it(`commits ${types.REQUEST_RELEASE}`, () => - testAction(actions.requestRelease, undefined, state, [{ type: types.REQUEST_RELEASE }])); - }); + describe('when creating a new release', () => { + beforeEach(() => { + setupState({ isExistingRelease: false }); + }); - describe('receiveReleaseSuccess', () => { - it(`commits ${types.RECEIVE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveReleaseSuccess, release, state, [ - { type: types.RECEIVE_RELEASE_SUCCESS, payload: release }, - ])); + describe('initializeRelease', () => { + it(`commits ${types.INITIALIZE_EMPTY_RELEASE}`, () => { + testAction(actions.initializeRelease, undefined, state, [ + { type: types.INITIALIZE_EMPTY_RELEASE }, + ]); + }); + }); + + describe('saveRelease', () => { + it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "createRelease"`, () => { + testAction( + actions.saveRelease, + undefined, + state, + [{ type: types.REQUEST_SAVE_RELEASE }], + [{ type: 'createRelease' }], + ); + }); + }); }); - describe('receiveReleaseError', () => { - it(`commits ${types.RECEIVE_RELEASE_ERROR}`, () => - testAction(actions.receiveReleaseError, error, state, [ - { type: types.RECEIVE_RELEASE_ERROR, payload: error }, - ])); + describe('when editing an existing release', () => { + beforeEach(setupState); - it('shows a flash with an error message', () => { - actions.receiveReleaseError({ commit: jest.fn() }, error); + describe('initializeRelease', () => { + it('dispatches "fetchRelease"', () => { + testAction(actions.initializeRelease, undefined, state, [], [{ type: 'fetchRelease' }]); + }); + }); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while getting the release details', - ); + describe('saveRelease', () => { + it(`commits ${types.REQUEST_SAVE_RELEASE} and then dispatched "updateRelease"`, () => { + testAction( + actions.saveRelease, + undefined, + state, + [{ type: types.REQUEST_SAVE_RELEASE }], + [{ type: 'updateRelease' }], + ); + }); }); }); - describe('fetchRelease', () => { - let getReleaseUrl; + describe('actions that behave the same whether creating a new release or editing an existing release', () => { + beforeEach(setupState); - beforeEach(() => { - state.projectId = '18'; - state.tagName = 'v1.3'; - getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`; - }); + describe('fetchRelease', () => { + let getReleaseUrl; + + beforeEach(() => { + getReleaseUrl = `/api/v4/projects/${state.projectId}/releases/${state.tagName}`; + }); + + describe('when the network request to the Release API is successful', () => { + beforeEach(() => { + mock.onGet(getReleaseUrl).replyOnce(httpStatus.OK, release); + }); + + it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_SUCCESS} with the converted release object`, () => { + return testAction(actions.fetchRelease, undefined, state, [ + { + type: types.REQUEST_RELEASE, + }, + { + type: types.RECEIVE_RELEASE_SUCCESS, + payload: apiJsonToRelease(release, { deep: true }), + }, + ]); + }); + }); - it(`dispatches requestRelease and receiveReleaseSuccess with the camel-case'd release object`, () => { - mock.onGet(getReleaseUrl).replyOnce(200, release); - - return testAction( - actions.fetchRelease, - undefined, - state, - [], - [ - { type: 'requestRelease' }, - { - type: 'receiveReleaseSuccess', - payload: convertObjectPropsToCamelCase(release, { deep: true }), - }, - ], - ); + describe('when the network request to the Release API fails', () => { + beforeEach(() => { + mock.onGet(getReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it(`commits ${types.REQUEST_RELEASE} and then commits ${types.RECEIVE_RELEASE_ERROR} with an error object`, () => { + return testAction(actions.fetchRelease, undefined, state, [ + { + type: types.REQUEST_RELEASE, + }, + { + type: types.RECEIVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ]); + }); + + it(`shows a flash message`, () => { + return actions.fetchRelease({ commit: jest.fn(), state }).then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while getting the release details', + ); + }); + }); + }); }); - it(`dispatches requestRelease and receiveReleaseError with an error object`, () => { - mock.onGet(getReleaseUrl).replyOnce(500); + describe('updateReleaseTagName', () => { + it(`commits ${types.UPDATE_RELEASE_TAG_NAME} with the updated tag name`, () => { + const newTag = 'updated-tag-name'; + return testAction(actions.updateReleaseTagName, newTag, state, [ + { type: types.UPDATE_RELEASE_TAG_NAME, payload: newTag }, + ]); + }); + }); - return testAction( - actions.fetchRelease, - undefined, - state, - [], - [{ type: 'requestRelease' }, { type: 'receiveReleaseError', payload: expect.anything() }], - ); + describe('updateCreateFrom', () => { + it(`commits ${types.UPDATE_CREATE_FROM} with the updated ref`, () => { + const newRef = 'my-feature-branch'; + return testAction(actions.updateCreateFrom, newRef, state, [ + { type: types.UPDATE_CREATE_FROM, payload: newRef }, + ]); + }); }); - }); - describe('updateReleaseTitle', () => { - it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { - const newTitle = 'The new release title'; - return testAction(actions.updateReleaseTitle, newTitle, state, [ - { type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, - ]); + describe('updateReleaseTitle', () => { + it(`commits ${types.UPDATE_RELEASE_TITLE} with the updated release title`, () => { + const newTitle = 'The new release title'; + return testAction(actions.updateReleaseTitle, newTitle, state, [ + { type: types.UPDATE_RELEASE_TITLE, payload: newTitle }, + ]); + }); }); - }); - describe('updateReleaseNotes', () => { - it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { - const newReleaseNotes = 'The new release notes'; - return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [ - { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, - ]); + describe('updateReleaseNotes', () => { + it(`commits ${types.UPDATE_RELEASE_NOTES} with the updated release notes`, () => { + const newReleaseNotes = 'The new release notes'; + return testAction(actions.updateReleaseNotes, newReleaseNotes, state, [ + { type: types.UPDATE_RELEASE_NOTES, payload: newReleaseNotes }, + ]); + }); }); - }); - describe('updateAssetLinkUrl', () => { - it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => { - const params = { - linkIdToUpdate: 2, - newUrl: 'https://example.com/updated', - }; + describe('updateReleaseMilestones', () => { + it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => { + const newReleaseMilestones = ['v0.0', 'v0.1']; + return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [ + { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, + ]); + }); + }); - return testAction(actions.updateAssetLinkUrl, params, state, [ - { type: types.UPDATE_ASSET_LINK_URL, payload: params }, - ]); + describe('addEmptyAssetLink', () => { + it(`commits ${types.ADD_EMPTY_ASSET_LINK}`, () => { + return testAction(actions.addEmptyAssetLink, undefined, state, [ + { type: types.ADD_EMPTY_ASSET_LINK }, + ]); + }); }); - }); - describe('updateAssetLinkName', () => { - it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => { - const params = { - linkIdToUpdate: 2, - newName: 'Updated link name', - }; + describe('updateAssetLinkUrl', () => { + it(`commits ${types.UPDATE_ASSET_LINK_URL} with the updated link URL`, () => { + const params = { + linkIdToUpdate: 2, + newUrl: 'https://example.com/updated', + }; - return testAction(actions.updateAssetLinkName, params, state, [ - { type: types.UPDATE_ASSET_LINK_NAME, payload: params }, - ]); + return testAction(actions.updateAssetLinkUrl, params, state, [ + { type: types.UPDATE_ASSET_LINK_URL, payload: params }, + ]); + }); }); - }); - describe('updateAssetLinkType', () => { - it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => { - const params = { - linkIdToUpdate: 2, - newType: ASSET_LINK_TYPE.RUNBOOK, - }; + describe('updateAssetLinkName', () => { + it(`commits ${types.UPDATE_ASSET_LINK_NAME} with the updated link name`, () => { + const params = { + linkIdToUpdate: 2, + newName: 'Updated link name', + }; - return testAction(actions.updateAssetLinkType, params, state, [ - { type: types.UPDATE_ASSET_LINK_TYPE, payload: params }, - ]); + return testAction(actions.updateAssetLinkName, params, state, [ + { type: types.UPDATE_ASSET_LINK_NAME, payload: params }, + ]); + }); }); - }); - describe('removeAssetLink', () => { - it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => { - const idToRemove = 2; - return testAction(actions.removeAssetLink, idToRemove, state, [ - { type: types.REMOVE_ASSET_LINK, payload: idToRemove }, - ]); + describe('updateAssetLinkType', () => { + it(`commits ${types.UPDATE_ASSET_LINK_TYPE} with the updated link type`, () => { + const params = { + linkIdToUpdate: 2, + newType: ASSET_LINK_TYPE.RUNBOOK, + }; + + return testAction(actions.updateAssetLinkType, params, state, [ + { type: types.UPDATE_ASSET_LINK_TYPE, payload: params }, + ]); + }); }); - }); - describe('updateReleaseMilestones', () => { - it(`commits ${types.UPDATE_RELEASE_MILESTONES} with the updated release milestones`, () => { - const newReleaseMilestones = ['v0.0', 'v0.1']; - return testAction(actions.updateReleaseMilestones, newReleaseMilestones, state, [ - { type: types.UPDATE_RELEASE_MILESTONES, payload: newReleaseMilestones }, - ]); + describe('removeAssetLink', () => { + it(`commits ${types.REMOVE_ASSET_LINK} with the ID of the asset link to remove`, () => { + const idToRemove = 2; + return testAction(actions.removeAssetLink, idToRemove, state, [ + { type: types.REMOVE_ASSET_LINK, payload: idToRemove }, + ]); + }); }); - }); - describe('requestUpdateRelease', () => { - it(`commits ${types.REQUEST_UPDATE_RELEASE}`, () => - testAction(actions.requestUpdateRelease, undefined, state, [ - { type: types.REQUEST_UPDATE_RELEASE }, - ])); - }); + describe('receiveSaveReleaseSuccess', () => { + it(`commits ${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => + testAction(actions.receiveSaveReleaseSuccess, undefined, { ...state, featureFlags: {} }, [ + { type: types.RECEIVE_SAVE_RELEASE_SUCCESS }, + ])); - describe('receiveUpdateReleaseSuccess', () => { - it(`commits ${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => - testAction(actions.receiveUpdateReleaseSuccess, undefined, { ...state, featureFlags: {} }, [ - { type: types.RECEIVE_UPDATE_RELEASE_SUCCESS }, - ])); + describe('when the releaseShowPage feature flag is enabled', () => { + beforeEach(() => { + const rootState = { featureFlags: { releaseShowPage: true } }; + actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release); + }); - it('redirects to the releases page if releaseShowPage feature flag is enabled', () => { - const rootState = { featureFlags: { releaseShowPage: true } }; - const updatedState = merge({}, state, { - releasesPagePath: 'path/to/releases/page', - release: { - _links: { - self: 'path/to/self', - }, - }, + it("redirects to the release's dedicated page", () => { + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(release._links.self); + }); }); - actions.receiveUpdateReleaseSuccess({ commit: jest.fn(), state: updatedState, rootState }); + describe('when the releaseShowPage feature flag is disabled', () => { + beforeEach(() => { + const rootState = { featureFlags: { releaseShowPage: false } }; + actions.receiveSaveReleaseSuccess({ commit: jest.fn(), state, rootState }, release); + }); - expect(redirectTo).toHaveBeenCalledTimes(1); - expect(redirectTo).toHaveBeenCalledWith(updatedState.release._links.self); + it("redirects to the project's main Releases page", () => { + expect(redirectTo).toHaveBeenCalledTimes(1); + expect(redirectTo).toHaveBeenCalledWith(state.releasesPagePath); + }); + }); }); - describe('when the releaseShowPage feature flag is disabled', () => {}); - }); - - describe('receiveUpdateReleaseError', () => { - it(`commits ${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => - testAction(actions.receiveUpdateReleaseError, error, state, [ - { type: types.RECEIVE_UPDATE_RELEASE_ERROR, payload: error }, - ])); + describe('createRelease', () => { + let createReleaseUrl; + let releaseLinksToCreate; - it('shows a flash with an error message', () => { - actions.receiveUpdateReleaseError({ commit: jest.fn() }, error); + beforeEach(() => { + const camelCasedRelease = convertObjectPropsToCamelCase(release); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith( - 'Something went wrong while saving the release details', - ); - }); - }); + releaseLinksToCreate = camelCasedRelease.assets.links.slice(0, 1); - describe('updateRelease', () => { - let getters; - let dispatch; - let callOrder; + setupState({ + release: camelCasedRelease, + releaseLinksToCreate, + }); - beforeEach(() => { - state.release = convertObjectPropsToCamelCase(release); - state.projectId = '18'; - state.tagName = state.release.tagName; + createReleaseUrl = `/api/v4/projects/${state.projectId}/releases`; + }); - getters = { - releaseLinksToDelete: [{ id: '1' }, { id: '2' }], - releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }], - }; + describe('when the network request to the Release API is successful', () => { + beforeEach(() => { + const expectedRelease = releaseToApiJson({ + ...state.release, + assets: { + links: releaseLinksToCreate, + }, + }); - dispatch = jest.fn(); + mock.onPost(createReleaseUrl, expectedRelease).replyOnce(httpStatus.CREATED, release); + }); - callOrder = []; - jest.spyOn(api, 'updateRelease').mockImplementation(() => { - callOrder.push('updateRelease'); - return Promise.resolve(); - }); - jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => { - callOrder.push('deleteReleaseLink'); - return Promise.resolve(); - }); - jest.spyOn(api, 'createReleaseLink').mockImplementation(() => { - callOrder.push('createReleaseLink'); - return Promise.resolve(); + it(`dispatches "receiveSaveReleaseSuccess" with the converted release object`, () => { + return testAction( + actions.createRelease, + undefined, + state, + [], + [ + { + type: 'receiveSaveReleaseSuccess', + payload: apiJsonToRelease(release, { deep: true }), + }, + ], + ); + }); }); - }); - it('dispatches requestUpdateRelease and receiveUpdateReleaseSuccess', () => { - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(dispatch.mock.calls).toEqual([ - ['requestUpdateRelease'], - ['receiveUpdateReleaseSuccess'], - ]); + describe('when the network request to the Release API fails', () => { + beforeEach(() => { + mock.onPost(createReleaseUrl).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + }); + + it(`commits ${types.RECEIVE_SAVE_RELEASE_ERROR} with an error object`, () => { + return testAction(actions.createRelease, undefined, state, [ + { + type: types.RECEIVE_SAVE_RELEASE_ERROR, + payload: expect.any(Error), + }, + ]); + }); + + it(`shows a flash message`, () => { + return actions + .createRelease({ commit: jest.fn(), dispatch: jest.fn(), state, getters: {} }) + .then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while creating a new release', + ); + }); + }); }); }); - it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => { - jest.spyOn(api, 'updateRelease').mockRejectedValue(error); + describe('updateRelease', () => { + let getters; + let dispatch; + let commit; + let callOrder; + + beforeEach(() => { + getters = { + releaseLinksToDelete: [{ id: '1' }, { id: '2' }], + releaseLinksToCreate: [{ id: 'new-link-1' }, { id: 'new-link-2' }], + }; + + setupState({ + release: convertObjectPropsToCamelCase(release), + ...getters, + }); - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(dispatch.mock.calls).toEqual([ - ['requestUpdateRelease'], - ['receiveUpdateReleaseError', error], - ]); + dispatch = jest.fn(); + commit = jest.fn(); + + callOrder = []; + jest.spyOn(api, 'updateRelease').mockImplementation(() => { + callOrder.push('updateRelease'); + return Promise.resolve({ data: release }); + }); + jest.spyOn(api, 'deleteReleaseLink').mockImplementation(() => { + callOrder.push('deleteReleaseLink'); + return Promise.resolve(); + }); + jest.spyOn(api, 'createReleaseLink').mockImplementation(() => { + callOrder.push('createReleaseLink'); + return Promise.resolve(); + }); }); - }); - it('updates the Release, then deletes all existing links, and then recreates new links', () => { - return actions.updateRelease({ dispatch, state, getters }).then(() => { - expect(callOrder).toEqual([ - 'updateRelease', - 'deleteReleaseLink', - 'deleteReleaseLink', - 'createReleaseLink', - 'createReleaseLink', - ]); + describe('when the network request to the Release API is successful', () => { + it('dispatches receiveSaveReleaseSuccess', () => { + return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { + expect(dispatch.mock.calls).toEqual([ + ['receiveSaveReleaseSuccess', apiJsonToRelease(release)], + ]); + }); + }); - expect(api.updateRelease.mock.calls).toEqual([ - [ - state.projectId, - state.tagName, - { - name: state.release.name, - description: state.release.description, - milestones: state.release.milestones.map(milestone => milestone.title), - }, - ], - ]); + it('updates the Release, then deletes all existing links, and then recreates new links', () => { + return actions.updateRelease({ dispatch, state, getters }).then(() => { + expect(callOrder).toEqual([ + 'updateRelease', + 'deleteReleaseLink', + 'deleteReleaseLink', + 'createReleaseLink', + 'createReleaseLink', + ]); + + expect(api.updateRelease.mock.calls).toEqual([ + [ + state.projectId, + state.tagName, + releaseToApiJson({ + ...state.release, + assets: { + links: getters.releaseLinksToCreate, + }, + }), + ], + ]); + + expect(api.deleteReleaseLink).toHaveBeenCalledTimes( + getters.releaseLinksToDelete.length, + ); + getters.releaseLinksToDelete.forEach(link => { + expect(api.deleteReleaseLink).toHaveBeenCalledWith( + state.projectId, + state.tagName, + link.id, + ); + }); + + expect(api.createReleaseLink).toHaveBeenCalledTimes( + getters.releaseLinksToCreate.length, + ); + getters.releaseLinksToCreate.forEach(link => { + expect(api.createReleaseLink).toHaveBeenCalledWith( + state.projectId, + state.tagName, + link, + ); + }); + }); + }); + }); - expect(api.deleteReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToDelete.length); - getters.releaseLinksToDelete.forEach(link => { - expect(api.deleteReleaseLink).toHaveBeenCalledWith( - state.projectId, - state.tagName, - link.id, - ); + describe('when the network request to the Release API fails', () => { + beforeEach(() => { + jest.spyOn(api, 'updateRelease').mockRejectedValue(error); + }); + + it('dispatches requestUpdateRelease and receiveUpdateReleaseError with an error object', () => { + return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { + expect(commit.mock.calls).toEqual([[types.RECEIVE_SAVE_RELEASE_ERROR, error]]); + }); }); - expect(api.createReleaseLink).toHaveBeenCalledTimes(getters.releaseLinksToCreate.length); - getters.releaseLinksToCreate.forEach(link => { - expect(api.createReleaseLink).toHaveBeenCalledWith(state.projectId, state.tagName, link); + it('shows a flash message', () => { + return actions.updateRelease({ commit, dispatch, state, getters }).then(() => { + expect(createFlash).toHaveBeenCalledTimes(1); + expect(createFlash).toHaveBeenCalledWith( + 'Something went wrong while saving the release details', + ); + }); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/getters_spec.js b/spec/frontend/releases/stores/modules/detail/getters_spec.js index 8945ad97c93..2d9f35428f2 100644 --- a/spec/frontend/releases/stores/modules/detail/getters_spec.js +++ b/spec/frontend/releases/stores/modules/detail/getters_spec.js @@ -1,6 +1,20 @@ import * as getters from '~/releases/stores/modules/detail/getters'; describe('Release detail getters', () => { + describe('isExistingRelease', () => { + it('returns true if the release is an existing release that already exists in the database', () => { + const state = { tagName: 'test-tag-name' }; + + expect(getters.isExistingRelease(state)).toBe(true); + }); + + it('returns false if the release is a new release that has not yet been saved to the database', () => { + const state = { tagName: null }; + + expect(getters.isExistingRelease(state)).toBe(false); + }); + }); + describe('releaseLinksToCreate', () => { it("returns an empty array if state.release doesn't exist", () => { const state = {}; @@ -62,6 +76,7 @@ describe('Release detail getters', () => { it('returns no validation errors', () => { const state = { release: { + tagName: 'test-tag-name', assets: { links: [ { id: 1, url: 'https://example.com/valid', name: 'Link 1' }, @@ -96,6 +111,9 @@ describe('Release detail getters', () => { beforeEach(() => { const state = { release: { + // empty tag name + tagName: '', + assets: { links: [ // Duplicate URLs @@ -124,7 +142,15 @@ describe('Release detail getters', () => { actualErrors = getters.validationErrors(state); }); - it('returns a validation errors if links share a URL', () => { + it('returns a validation error if the tag name is empty', () => { + const expectedErrors = { + isTagNameEmpty: true, + }; + + expect(actualErrors).toMatchObject(expectedErrors); + }); + + it('returns a validation error if links share a URL', () => { const expectedErrors = { assets: { links: { @@ -182,32 +208,53 @@ describe('Release detail getters', () => { // the value of state is not actually used by this getter const state = {}; - it('returns true when the form is valid', () => { - const mockGetters = { - validationErrors: { - assets: { - links: { - 1: {}, + describe('when the form is valid', () => { + it('returns true', () => { + const mockGetters = { + validationErrors: { + assets: { + links: { + 1: {}, + }, }, }, - }, - }; + }; - expect(getters.isValid(state, mockGetters)).toBe(true); + expect(getters.isValid(state, mockGetters)).toBe(true); + }); }); - it('returns false when the form is invalid', () => { - const mockGetters = { - validationErrors: { - assets: { - links: { - 1: { isNameEmpty: true }, + describe('when an asset link contains a validation error', () => { + it('returns false', () => { + const mockGetters = { + validationErrors: { + assets: { + links: { + 1: { isNameEmpty: true }, + }, }, }, - }, - }; + }; - expect(getters.isValid(state, mockGetters)).toBe(false); + expect(getters.isValid(state, mockGetters)).toBe(false); + }); + }); + + describe('when the tag name is empty', () => { + it('returns false', () => { + const mockGetters = { + validationErrors: { + isTagNameEmpty: true, + assets: { + links: { + 1: {}, + }, + }, + }, + }; + + expect(getters.isValid(state, mockGetters)).toBe(false); + }); }); }); }); diff --git a/spec/frontend/releases/stores/modules/detail/mutations_spec.js b/spec/frontend/releases/stores/modules/detail/mutations_spec.js index a34c1be64d9..cd7c6b7d275 100644 --- a/spec/frontend/releases/stores/modules/detail/mutations_spec.js +++ b/spec/frontend/releases/stores/modules/detail/mutations_spec.js @@ -21,6 +21,22 @@ describe('Release detail mutations', () => { release = convertObjectPropsToCamelCase(originalRelease); }); + describe(`${types.INITIALIZE_EMPTY_RELEASE}`, () => { + it('set state.release to an empty release object', () => { + mutations[types.INITIALIZE_EMPTY_RELEASE](state); + + expect(state.release).toEqual({ + tagName: null, + name: '', + description: '', + milestones: [], + assets: { + links: [], + }, + }); + }); + }); + describe(`${types.REQUEST_RELEASE}`, () => { it('set state.isFetchingRelease to true', () => { mutations[types.REQUEST_RELEASE](state); @@ -56,6 +72,26 @@ describe('Release detail mutations', () => { }); }); + describe(`${types.UPDATE_RELEASE_TAG_NAME}`, () => { + it("updates the release's tag name", () => { + state.release = release; + const newTag = 'updated-tag-name'; + mutations[types.UPDATE_RELEASE_TAG_NAME](state, newTag); + + expect(state.release.tagName).toBe(newTag); + }); + }); + + describe(`${types.UPDATE_CREATE_FROM}`, () => { + it('updates the ref that the ref will be created from', () => { + state.createFrom = 'main'; + const newRef = 'my-feature-branch'; + mutations[types.UPDATE_CREATE_FROM](state, newRef); + + expect(state.createFrom).toBe(newRef); + }); + }); + describe(`${types.UPDATE_RELEASE_TITLE}`, () => { it("updates the release's title", () => { state.release = release; @@ -76,17 +112,17 @@ describe('Release detail mutations', () => { }); }); - describe(`${types.REQUEST_UPDATE_RELEASE}`, () => { + describe(`${types.REQUEST_SAVE_RELEASE}`, () => { it('set state.isUpdatingRelease to true', () => { - mutations[types.REQUEST_UPDATE_RELEASE](state); + mutations[types.REQUEST_SAVE_RELEASE](state); expect(state.isUpdatingRelease).toBe(true); }); }); - describe(`${types.RECEIVE_UPDATE_RELEASE_SUCCESS}`, () => { + describe(`${types.RECEIVE_SAVE_RELEASE_SUCCESS}`, () => { it('handles a successful response from the server', () => { - mutations[types.RECEIVE_UPDATE_RELEASE_SUCCESS](state, release); + mutations[types.RECEIVE_SAVE_RELEASE_SUCCESS](state, release); expect(state.updateError).toBeUndefined(); @@ -94,10 +130,10 @@ describe('Release detail mutations', () => { }); }); - describe(`${types.RECEIVE_UPDATE_RELEASE_ERROR}`, () => { + describe(`${types.RECEIVE_SAVE_RELEASE_ERROR}`, () => { it('handles an unsuccessful response from the server', () => { const error = { message: 'An error occurred!' }; - mutations[types.RECEIVE_UPDATE_RELEASE_ERROR](state, error); + mutations[types.RECEIVE_SAVE_RELEASE_ERROR](state, error); expect(state.isUpdatingRelease).toBe(false); diff --git a/spec/frontend/releases/util_spec.js b/spec/frontend/releases/util_spec.js new file mode 100644 index 00000000000..90aa9c4c7d8 --- /dev/null +++ b/spec/frontend/releases/util_spec.js @@ -0,0 +1,103 @@ +import { releaseToApiJson, apiJsonToRelease } from '~/releases/util'; + +describe('releases/util.js', () => { + describe('releaseToApiJson', () => { + it('converts a release JavaScript object into JSON that the Release API can accept', () => { + const release = { + tagName: 'tag-name', + name: 'Release name', + description: 'Release description', + milestones: [{ id: 1, title: '13.2' }, { id: 2, title: '13.3' }], + assets: { + links: [{ url: 'https://gitlab.example.com/link', linkType: 'other' }], + }, + }; + + const expectedJson = { + tag_name: 'tag-name', + ref: null, + name: 'Release name', + description: 'Release description', + milestones: ['13.2', '13.3'], + assets: { + links: [{ url: 'https://gitlab.example.com/link', link_type: 'other' }], + }, + }; + + expect(releaseToApiJson(release)).toEqual(expectedJson); + }); + + describe('when createFrom is provided', () => { + it('adds the provided createFrom ref to the JSON as a "ref" property', () => { + const createFrom = 'main'; + + const release = {}; + + const expectedJson = { + ref: createFrom, + }; + + expect(releaseToApiJson(release, createFrom)).toMatchObject(expectedJson); + }); + }); + + describe('release.name', () => { + it.each` + input | output + ${null} | ${null} + ${''} | ${null} + ${' \t\n\r\n'} | ${null} + ${' Release name '} | ${'Release name'} + `('converts a name like `$input` to `$output`', ({ input, output }) => { + const release = { name: input }; + + const expectedJson = { + name: output, + }; + + expect(releaseToApiJson(release)).toMatchObject(expectedJson); + }); + }); + + describe('when release.milestones is falsy', () => { + it('includes a "milestone" property in the returned result as an empty array', () => { + const release = {}; + + const expectedJson = { + milestones: [], + }; + + expect(releaseToApiJson(release)).toMatchObject(expectedJson); + }); + }); + }); + + describe('apiJsonToRelease', () => { + it('converts JSON received from the Release API into an object usable by the Vue application', () => { + const json = { + tag_name: 'tag-name', + assets: { + links: [ + { + link_type: 'other', + }, + ], + }, + }; + + const expectedRelease = { + tagName: 'tag-name', + assets: { + links: [ + { + linkType: 'other', + }, + ], + }, + milestones: [], + }; + + expect(apiJsonToRelease(json)).toEqual(expectedRelease); + }); + }); +}); diff --git a/spec/frontend/reports/accessibility_report/mock_data.js b/spec/frontend/reports/accessibility_report/mock_data.js index f8e832c1ce5..20ad01bd802 100644 --- a/spec/frontend/reports/accessibility_report/mock_data.js +++ b/spec/frontend/reports/accessibility_report/mock_data.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export export const mockReport = { status: 'failed', summary: { @@ -51,5 +52,3 @@ export const mockReport = { existing_notes: [], existing_warnings: [], }; - -export default () => {}; diff --git a/spec/frontend/reports/accessibility_report/store/actions_spec.js b/spec/frontend/reports/accessibility_report/store/actions_spec.js index 129a5bade86..9f210659cfd 100644 --- a/spec/frontend/reports/accessibility_report/store/actions_spec.js +++ b/spec/frontend/reports/accessibility_report/store/actions_spec.js @@ -1,10 +1,10 @@ -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; import * as actions from '~/reports/accessibility_report/store/actions'; import * as types from '~/reports/accessibility_report/store/mutation_types'; import createStore from '~/reports/accessibility_report/store'; -import { TEST_HOST } from 'spec/test_constants'; -import testAction from 'helpers/vuex_action_helper'; import { mockReport } from '../mock_data'; describe('Accessibility Reports actions', () => { diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/reports/codequality_report/store/actions_spec.js index 6c30fdb7871..7d9e4bbbe9f 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/reports/codequality_report/store/actions_spec.js @@ -1,10 +1,10 @@ -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import { TEST_HOST } from 'spec/test_constants'; +import testAction from 'helpers/vuex_action_helper'; +import axios from '~/lib/utils/axios_utils'; import * as actions from '~/reports/codequality_report/store/actions'; import * as types from '~/reports/codequality_report/store/mutation_types'; import createStore from '~/reports/codequality_report/store'; -import { TEST_HOST } from 'spec/test_constants'; -import testAction from 'helpers/vuex_action_helper'; import { headIssues, baseIssues, mockParsedHeadIssues, mockParsedBaseIssues } from '../mock_data'; // mock codequality comparison worker 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 017e0335569..c26e2fbc19a 100644 --- a/spec/frontend/reports/components/grouped_test_reports_app_spec.js +++ b/spec/frontend/reports/components/grouped_test_reports_app_spec.js @@ -20,10 +20,7 @@ describe('Grouped test reports app', () => { let wrapper; let mockStore; - const mountComponent = ({ - glFeatures = { junitPipelineView: false }, - props = { pipelinePath }, - } = {}) => { + const mountComponent = ({ props = { pipelinePath } } = {}) => { wrapper = mount(Component, { store: mockStore, localVue, @@ -35,9 +32,6 @@ describe('Grouped test reports app', () => { methods: { fetchReports: () => {}, }, - provide: { - glFeatures, - }, }); }; @@ -78,28 +72,17 @@ describe('Grouped test reports app', () => { }); describe('`View full report` button', () => { - it('should not render the full test report link', () => { - expect(findFullTestReportLink().exists()).toBe(false); - }); + it('should render the full test report link', () => { + const fullTestReportLink = findFullTestReportLink(); - describe('With junitPipelineView feature flag enabled', () => { - beforeEach(() => { - mountComponent({ glFeatures: { junitPipelineView: true } }); - }); - - it('should render the full test report link', () => { - const fullTestReportLink = findFullTestReportLink(); - - expect(fullTestReportLink.exists()).toBe(true); - expect(pipelinePath).not.toBe(''); - expect(fullTestReportLink.attributes('href')).toBe(`${pipelinePath}/test_report`); - }); + expect(fullTestReportLink.exists()).toBe(true); + expect(pipelinePath).not.toBe(''); + expect(fullTestReportLink.attributes('href')).toBe(`${pipelinePath}/test_report`); }); describe('Without a pipelinePath', () => { beforeEach(() => { mountComponent({ - glFeatures: { junitPipelineView: true }, props: { pipelinePath: '' }, }); }); diff --git a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap index 1dca65dd862..cf2e6b00800 100644 --- a/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap +++ b/spec/frontend/repository/components/__snapshots__/last_commit_spec.js.snap @@ -10,7 +10,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` imgcssclasses="" imgsize="40" imgsrc="https://test.com" - linkhref="https://test.com/test" + linkhref="/test" tooltipplacement="top" tooltiptext="" username="" @@ -24,7 +24,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` > <gl-link-stub class="commit-row-message item-title" - href="https://test.com/commit/123" + href="/commit/123" > Commit title </gl-link-stub> @@ -36,7 +36,7 @@ exports[`Repository last commit component renders commit widget 1`] = ` > <gl-link-stub class="commit-author-link js-user-link" - href="https://test.com/test" + href="/test" > Test @@ -110,7 +110,7 @@ exports[`Repository last commit component renders the signature HTML as returned imgcssclasses="" imgsize="40" imgsrc="https://test.com" - linkhref="https://test.com/test" + linkhref="/test" tooltipplacement="top" tooltiptext="" username="" @@ -124,7 +124,7 @@ exports[`Repository last commit component renders the signature HTML as returned > <gl-link-stub class="commit-row-message item-title" - href="https://test.com/commit/123" + href="/commit/123" > Commit title </gl-link-stub> @@ -136,7 +136,7 @@ exports[`Repository last commit component renders the signature HTML as returned > <gl-link-stub class="commit-author-link js-user-link" - href="https://test.com/test" + href="/test" > Test diff --git a/spec/frontend/repository/components/breadcrumbs_spec.js b/spec/frontend/repository/components/breadcrumbs_spec.js index 38e5c9aaca5..ca4120576f5 100644 --- a/spec/frontend/repository/components/breadcrumbs_spec.js +++ b/spec/frontend/repository/components/breadcrumbs_spec.js @@ -1,5 +1,5 @@ import { shallowMount, RouterLinkStub } from '@vue/test-utils'; -import { GlDropdown } from '@gitlab/ui'; +import { GlDeprecatedDropdown } from '@gitlab/ui'; import Breadcrumbs from '~/repository/components/breadcrumbs.vue'; let vm; @@ -61,7 +61,7 @@ describe('Repository breadcrumbs component', () => { vm.setData({ userPermissions: { forkProject: false, createMergeRequestIn: false } }); return vm.vm.$nextTick(() => { - expect(vm.find(GlDropdown).exists()).toBe(false); + expect(vm.find(GlDeprecatedDropdown).exists()).toBe(false); }); }); @@ -71,7 +71,7 @@ describe('Repository breadcrumbs component', () => { vm.setData({ userPermissions: { forkProject: true, createMergeRequestIn: true } }); return vm.vm.$nextTick(() => { - expect(vm.find(GlDropdown).exists()).toBe(true); + expect(vm.find(GlDeprecatedDropdown).exists()).toBe(true); }); }); }); diff --git a/spec/frontend/repository/components/last_commit_spec.js b/spec/frontend/repository/components/last_commit_spec.js index a5bfeb08fe4..c14a7f0e061 100644 --- a/spec/frontend/repository/components/last_commit_spec.js +++ b/spec/frontend/repository/components/last_commit_spec.js @@ -11,12 +11,12 @@ function createCommitData(data = {}) { title: 'Commit title', titleHtml: 'Commit title', message: 'Commit message', - webUrl: 'https://test.com/commit/123', + webPath: '/commit/123', authoredDate: '2019-01-01', author: { name: 'Test', avatarUrl: 'https://test.com', - webUrl: 'https://test.com/test', + webPath: '/test', }, pipeline: { detailedStatus: { @@ -108,7 +108,7 @@ describe('Repository last commit component', () => { }); it('does not render description expander when description is null', () => { - factory(createCommitData({ description: null })); + factory(createCommitData({ descriptionHtml: null })); return vm.vm.$nextTick(() => { expect(vm.find('.text-expander').exists()).toBe(false); @@ -117,7 +117,7 @@ describe('Repository last commit component', () => { }); it('expands commit description when clicking expander', () => { - factory(createCommitData({ description: 'Test description' })); + factory(createCommitData({ descriptionHtml: 'Test description' })); return vm.vm .$nextTick() diff --git a/spec/frontend/repository/components/preview/index_spec.js b/spec/frontend/repository/components/preview/index_spec.js index 6ae323f5c3f..ebd985e640c 100644 --- a/spec/frontend/repository/components/preview/index_spec.js +++ b/spec/frontend/repository/components/preview/index_spec.js @@ -30,7 +30,7 @@ describe('Repository file preview component', () => { it('renders file HTML', () => { factory({ - webUrl: 'http://test.com', + webPath: 'http://test.com', name: 'README.md', }); @@ -43,7 +43,7 @@ describe('Repository file preview component', () => { it('handles hash after render', () => { factory({ - webUrl: 'http://test.com', + webPath: 'http://test.com', name: 'README.md', }); @@ -59,7 +59,7 @@ describe('Repository file preview component', () => { it('renders loading icon', () => { factory({ - webUrl: 'http://test.com', + webPath: 'http://test.com', name: 'README.md', }); diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index ed50f292b8c..10669330b61 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -13,7 +13,7 @@ const MOCK_BLOBS = [ flatPath: 'blob', name: 'blob.md', type: 'blob', - webUrl: 'http://test.com', + webPath: '/blob', }, { id: '124abc', diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index da892ce51d8..ea85cd34743 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -1,5 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import TreeContent from '~/repository/components/tree_content.vue'; +import { GlButton } from '@gitlab/ui'; +import TreeContent, { INITIAL_FETCH_COUNT } from '~/repository/components/tree_content.vue'; import FilePreview from '~/repository/components/preview/index.vue'; let vm; @@ -25,14 +26,24 @@ describe('Repository table component', () => { vm.destroy(); }); - it('renders file preview', () => { + it('renders file preview', async () => { factory('/'); vm.setData({ entries: { blobs: [{ name: 'README.md' }] } }); - return vm.vm.$nextTick().then(() => { - expect(vm.find(FilePreview).exists()).toBe(true); - }); + await vm.vm.$nextTick(); + + expect(vm.find(FilePreview).exists()).toBe(true); + }); + + it('trigger fetchFiles when mounted', async () => { + factory('/'); + + jest.spyOn(vm.vm, 'fetchFiles').mockImplementation(() => {}); + + await vm.vm.$nextTick(); + + expect(vm.vm.fetchFiles).toHaveBeenCalled(); }); describe('normalizeData', () => { @@ -70,4 +81,59 @@ describe('Repository table component', () => { expect(output).toEqual({ hasNextPage: true, nextCursor: 'test' }); }); }); + + describe('Show more button', () => { + const showMoreButton = () => vm.find(GlButton); + + describe('when is present', () => { + 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'); + await vm.vm.$nextTick(); + + expect(showMoreButton().exists()).toBe(false); + }); + + it('is rendered', async () => { + expect(showMoreButton().exists()).toBe(true); + }); + + it('changes clickedShowMore when show more button is clicked', async () => { + showMoreButton().vm.$emit('click'); + + expect(vm.vm.clickedShowMore).toBe(true); + }); + + it('triggers fetchFiles when show more button is clicked', async () => { + jest.spyOn(vm.vm, 'fetchFiles'); + + showMoreButton().vm.$emit('click'); + + expect(vm.vm.fetchFiles).toBeCalled(); + }); + }); + + it('is not rendered if less than 1000 files', async () => { + factory('/'); + + vm.setData({ fetchCounter: 5, clickedShowMore: false }); + + await vm.vm.$nextTick(); + + expect(showMoreButton().exists()).toBe(false); + }); + + it('has limit of 1000 files on initial load', () => { + factory('/'); + + expect(INITIAL_FETCH_COUNT * vm.vm.pageSize).toBe(1000); + }); + }); }); diff --git a/spec/frontend/repository/components/web_ide_link_spec.js b/spec/frontend/repository/components/web_ide_link_spec.js index 59e1a4fd719..877756db364 100644 --- a/spec/frontend/repository/components/web_ide_link_spec.js +++ b/spec/frontend/repository/components/web_ide_link_spec.js @@ -1,5 +1,5 @@ -import WebIdeLink from '~/repository/components/web_ide_link.vue'; import { mount } from '@vue/test-utils'; +import WebIdeLink from '~/repository/components/web_ide_link.vue'; describe('Web IDE link component', () => { let wrapper; diff --git a/spec/frontend/repository/utils/dom_spec.js b/spec/frontend/repository/utils/dom_spec.js index e8b0565868e..26ed57f0392 100644 --- a/spec/frontend/repository/utils/dom_spec.js +++ b/spec/frontend/repository/utils/dom_spec.js @@ -1,6 +1,6 @@ +import { TEST_HOST } from 'helpers/test_constants'; import { setHTMLFixture } from '../../helpers/fixtures'; import { updateElementsVisibility, updateFormAction } from '~/repository/utils/dom'; -import { TEST_HOST } from 'helpers/test_constants'; describe('updateElementsVisibility', () => { it('adds hidden class', () => { diff --git a/spec/frontend/search_autocomplete_spec.js b/spec/frontend/search_autocomplete_spec.js index 05b36474548..ee46dc015af 100644 --- a/spec/frontend/search_autocomplete_spec.js +++ b/spec/frontend/search_autocomplete_spec.js @@ -2,10 +2,11 @@ import $ from 'jquery'; import '~/gl_dropdown'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import initSearchAutocomplete from '~/search_autocomplete'; import '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; -import AxiosMockAdapter from 'axios-mock-adapter'; describe('Search autocomplete dropdown', () => { let widget = null; @@ -274,11 +275,32 @@ describe('Search autocomplete dropdown', () => { }); describe('enableAutocomplete', () => { + let toggleSpy; + let trackingSpy; + + beforeEach(() => { + toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + document.body.dataset.page = 'some:page'; // default tracking for category + }); + + afterEach(() => { + unmockTracking(); + }); + it('should open the Dropdown', () => { - const toggleSpy = jest.spyOn(widget.dropdownToggle, 'dropdown'); widget.enableAutocomplete(); expect(toggleSpy).toHaveBeenCalledWith('toggle'); }); + + it('should track the opening', () => { + widget.enableAutocomplete(); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_search_bar', { + label: 'main_navigation', + property: 'navigation', + }); + }); }); }); diff --git a/spec/frontend/self_monitor/components/self_monitor_form_spec.js b/spec/frontend/self_monitor/components/self_monitor_form_spec.js index aa6f71b6412..ec5f7b0a394 100644 --- a/spec/frontend/self_monitor/components/self_monitor_form_spec.js +++ b/spec/frontend/self_monitor/components/self_monitor_form_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { GlDeprecatedButton } from '@gitlab/ui'; +import { TEST_HOST } from 'helpers/test_constants'; import SelfMonitor from '~/self_monitor/components/self_monitor_form.vue'; import { createStore } from '~/self_monitor/store'; -import { TEST_HOST } from 'helpers/test_constants'; describe('self monitor component', () => { let wrapper; diff --git a/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap new file mode 100644 index 00000000000..22689080063 --- /dev/null +++ b/spec/frontend/serverless/components/__snapshots__/empty_state_spec.js.snap @@ -0,0 +1,20 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`EmptyStateComponent should render content 1`] = ` +"<section class=\\"row empty-state text-center\\"> + <div class=\\"col-12\\"> + <div class=\\"svg-250 svg-content\\"><img src=\\"/image.svg\\" alt=\\"Getting started with serverless\\" class=\\"gl-max-w-full\\"></div> + </div> + <div class=\\"col-12\\"> + <div class=\\"text-content gl-mx-auto gl-my-0 gl-p-5\\"> + <h1 class=\\"h4\\">Getting started with serverless</h1> + <p>In order to start using functions as a service, you must first install Knative on your Kubernetes cluster. <gl-link-stub href=\\"/help\\">More information</gl-link-stub> + </p> + <div> + <gl-button-stub category=\\"primary\\" variant=\\"success\\" size=\\"medium\\" icon=\\"\\" href=\\"/clusters\\">Install Knative</gl-button-stub> + <!----> + </div> + </div> + </div> +</section>" +`; diff --git a/spec/frontend/serverless/components/empty_state_spec.js b/spec/frontend/serverless/components/empty_state_spec.js new file mode 100644 index 00000000000..daa1576a4ec --- /dev/null +++ b/spec/frontend/serverless/components/empty_state_spec.js @@ -0,0 +1,25 @@ +import { GlEmptyState, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/serverless/store'; +import EmptyStateComponent from '~/serverless/components/empty_state.vue'; + +describe('EmptyStateComponent', () => { + let wrapper; + + beforeEach(() => { + const store = createStore({ + clustersPath: '/clusters', + helpPath: '/help', + emptyImagePath: '/image.svg', + }); + wrapper = shallowMount(EmptyStateComponent, { store, stubs: { GlEmptyState, GlSprintf } }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should render content', () => { + expect(wrapper.html()).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/serverless/components/function_details_spec.js b/spec/frontend/serverless/components/function_details_spec.js index 40d2bbb0291..248dd418941 100644 --- a/spec/frontend/serverless/components/function_details_spec.js +++ b/spec/frontend/serverless/components/function_details_spec.js @@ -13,7 +13,7 @@ describe('functionDetailsComponent', () => { localVue = createLocalVue(); localVue.use(Vuex); - store = createStore(); + store = createStore({ clustersPath: '/clusters', helpPath: '/help' }); }); afterEach(() => { @@ -38,8 +38,6 @@ describe('functionDetailsComponent', () => { propsData: { func: serviceStub, hasPrometheus: false, - clustersPath: '/clusters', - helpPath: '/help', }, }); @@ -65,8 +63,6 @@ describe('functionDetailsComponent', () => { propsData: { func: serviceStub, hasPrometheus: false, - clustersPath: '/clusters', - helpPath: '/help', }, }); @@ -82,8 +78,6 @@ describe('functionDetailsComponent', () => { propsData: { func: serviceStub, hasPrometheus: false, - clustersPath: '/clusters', - helpPath: '/help', }, }); @@ -99,8 +93,6 @@ describe('functionDetailsComponent', () => { propsData: { func: serviceStub, hasPrometheus: false, - clustersPath: '/clusters', - helpPath: '/help', }, }); diff --git a/spec/frontend/serverless/components/functions_spec.js b/spec/frontend/serverless/components/functions_spec.js index 8db04409357..0fca027fe56 100644 --- a/spec/frontend/serverless/components/functions_spec.js +++ b/spec/frontend/serverless/components/functions_spec.js @@ -25,55 +25,31 @@ describe('functionsComponent', () => { localVue = createLocalVue(); localVue.use(Vuex); - store = createStore(); + store = createStore({}); }); afterEach(() => { - component.vm.$destroy(); + component.destroy(); axiosMock.restore(); }); it('should render empty state when Knative is not installed', () => { store.dispatch('receiveFunctionsSuccess', { knative_installed: false }); - component = shallowMount(functionsComponent, { - localVue, - store, - propsData: { - clustersPath: '', - helpPath: '', - statusPath: '', - }, - }); + component = shallowMount(functionsComponent, { localVue, store }); expect(component.find(EmptyState).exists()).toBe(true); }); it('should render a loading component', () => { store.dispatch('requestFunctionsLoading'); - component = shallowMount(functionsComponent, { - localVue, - store, - propsData: { - clustersPath: '', - helpPath: '', - statusPath: '', - }, - }); + component = shallowMount(functionsComponent, { localVue, store }); expect(component.find(GlLoadingIcon).exists()).toBe(true); }); it('should render empty state when there is no function data', () => { store.dispatch('receiveFunctionsNoDataSuccess', { knative_installed: true }); - component = shallowMount(functionsComponent, { - localVue, - store, - propsData: { - clustersPath: '', - helpPath: '', - statusPath: '', - }, - }); + component = shallowMount(functionsComponent, { localVue, store }); expect( component.vm.$el @@ -91,30 +67,17 @@ describe('functionsComponent', () => { ...mockServerlessFunctions, knative_installed: 'checking', }); - component = shallowMount(functionsComponent, { - localVue, - store, - propsData: { - clustersPath: '', - helpPath: '', - statusPath: '', - }, - }); + + component = shallowMount(functionsComponent, { localVue, store }); expect(component.find('.js-functions-wrapper').exists()).toBe(true); expect(component.find('.js-functions-loader').exists()).toBe(true); }); it('should render the functions list', () => { - component = shallowMount(functionsComponent, { - localVue, - store, - propsData: { - clustersPath: 'clustersPath', - helpPath: 'helpPath', - statusPath, - }, - }); + store = createStore({ clustersPath: 'clustersPath', helpPath: 'helpPath', statusPath }); + + component = shallowMount(functionsComponent, { localVue, store }); component.vm.$store.dispatch('receiveFunctionsSuccess', mockServerlessFunctions); diff --git a/spec/frontend/serverless/components/missing_prometheus_spec.js b/spec/frontend/serverless/components/missing_prometheus_spec.js index 90730765f7c..9ca4a45dd5f 100644 --- a/spec/frontend/serverless/components/missing_prometheus_spec.js +++ b/spec/frontend/serverless/components/missing_prometheus_spec.js @@ -1,25 +1,23 @@ import { GlDeprecatedButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import { createStore } from '~/serverless/store'; import missingPrometheusComponent from '~/serverless/components/missing_prometheus.vue'; -const createComponent = missingData => - shallowMount(missingPrometheusComponent, { - propsData: { - clustersPath: '/clusters', - helpPath: '/help', - missingData, - }, - }); - describe('missingPrometheusComponent', () => { let wrapper; + const createComponent = missingData => { + const store = createStore({ clustersPath: '/clusters', helpPath: '/help' }); + + wrapper = shallowMount(missingPrometheusComponent, { store, propsData: { missingData } }); + }; + afterEach(() => { wrapper.destroy(); }); it('should render missing prometheus message', () => { - wrapper = createComponent(false); + createComponent(false); const { vm } = wrapper; expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( @@ -30,7 +28,7 @@ describe('missingPrometheusComponent', () => { }); it('should render no prometheus data message', () => { - wrapper = createComponent(true); + createComponent(true); const { vm } = wrapper; expect(vm.$el.querySelector('.state-description').innerHTML.trim()).toContain( diff --git a/spec/frontend/serverless/survey_banner_spec.js b/spec/frontend/serverless/survey_banner_spec.js index 15e9c6ec350..29b36fb9b5f 100644 --- a/spec/frontend/serverless/survey_banner_spec.js +++ b/spec/frontend/serverless/survey_banner_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; import Cookies from 'js-cookie'; -import SurveyBanner from '~/serverless/survey_banner.vue'; import { GlBanner } from '@gitlab/ui'; +import SurveyBanner from '~/serverless/survey_banner.vue'; describe('Knative survey banner', () => { let wrapper; diff --git a/spec/frontend/serverless/utils.js b/spec/frontend/serverless/utils.js index 5ce2e37d493..ba451b7d573 100644 --- a/spec/frontend/serverless/utils.js +++ b/spec/frontend/serverless/utils.js @@ -1,3 +1,4 @@ +// eslint-disable-next-line import/prefer-default-export export const adjustMetricQuery = data => { const updatedMetric = data.metrics; @@ -15,6 +16,3 @@ export const adjustMetricQuery = data => { updatedMetric.queries = queries; return updatedMetric; }; - -// prevent babel-plugin-rewire from generating an invalid default during karma tests -export default () => {}; 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 da571af3a0d..4c1ab4a499c 100644 --- a/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap +++ b/spec/frontend/sidebar/__snapshots__/confidential_issue_sidebar_spec.js.snap @@ -49,8 +49,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i </div> </div> - - <!----> </div> `; @@ -111,8 +109,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = false and i </div> </div> - - <!----> </div> `; @@ -164,8 +160,6 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is </div> </div> - - <!----> </div> `; @@ -225,7 +219,5 @@ exports[`Confidential Issue Sidebar Block renders for confidential = true and is </div> </div> - - <!----> </div> `; diff --git a/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap new file mode 100644 index 00000000000..d33f6c7f389 --- /dev/null +++ b/spec/frontend/sidebar/confidential/__snapshots__/edit_form_spec.js.snap @@ -0,0 +1,50 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Edit Form Dropdown when confidential renders on or off text based on confidentiality 1`] = ` +<div + class="dropdown show" + toggleform="function () {}" + updateconfidentialattribute="function () {}" +> + <div + class="dropdown-menu sidebar-item-warning-message" + > + <div> + <p> + <gl-sprintf-stub + message="You are going to turn off the confidentiality. This means %{strongStart}everyone%{strongEnd} will be able to see and leave a comment on this %{issuableType}." + /> + </p> + + <edit-form-buttons-stub + confidential="true" + fullpath="" + /> + </div> + </div> +</div> +`; + +exports[`Edit Form Dropdown when not confidential renders "You are going to turn on the confidentiality." in the 1`] = ` +<div + class="dropdown show" + toggleform="function () {}" + updateconfidentialattribute="function () {}" +> + <div + class="dropdown-menu sidebar-item-warning-message" + > + <div> + <p> + <gl-sprintf-stub + message="You are going to turn on the confidentiality. This means that only team members with %{strongStart}at least Reporter access%{strongEnd} are able to see and leave comments on the %{issuableType}." + /> + </p> + + <edit-form-buttons-stub + fullpath="" + /> + </div> + </div> +</div> +`; diff --git a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js index 15493d3087f..2f11c6a07c2 100644 --- a/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/confidential/edit_form_buttons_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { GlLoadingIcon } from '@gitlab/ui'; +import waitForPromises from 'helpers/wait_for_promises'; import EditFormButtons from '~/sidebar/components/confidential/edit_form_buttons.vue'; import eventHub from '~/sidebar/event_hub'; import createStore from '~/notes/stores'; -import waitForPromises from 'helpers/wait_for_promises'; -import flash from '~/flash'; +import { deprecatedCreateFlash as flash } from '~/flash'; jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() })); jest.mock('~/flash'); @@ -14,12 +14,7 @@ describe('Edit Form Buttons', () => { let store; const findConfidentialToggle = () => wrapper.find('[data-testid="confidential-toggle"]'); - const createComponent = ({ - props = {}, - data = {}, - confidentialApolloSidebar = false, - resolved = true, - }) => { + const createComponent = ({ props = {}, data = {}, resolved = true }) => { store = createStore(); if (resolved) { jest.spyOn(store, 'dispatch').mockResolvedValue(); @@ -38,11 +33,6 @@ describe('Edit Form Buttons', () => { ...data, }; }, - provide: { - glFeatures: { - confidentialApolloSidebar, - }, - }, store, }); }; @@ -54,9 +44,11 @@ describe('Edit Form Buttons', () => { describe('when isLoading', () => { beforeEach(() => { - createComponent({}); - - wrapper.vm.$store.state.noteableData.confidential = false; + createComponent({ + props: { + confidential: false, + }, + }); }); it('renders "Applying" in the toggle button', () => { @@ -78,6 +70,9 @@ describe('Edit Form Buttons', () => { data: { isLoading: false, }, + props: { + confidential: false, + }, }); expect(findConfidentialToggle().text()).toBe('Turn On'); @@ -90,70 +85,63 @@ describe('Edit Form Buttons', () => { data: { isLoading: false, }, + props: { + confidential: true, + }, }); - - wrapper.vm.$store.state.noteableData.confidential = true; }); it('renders on or off text based on confidentiality', () => { expect(findConfidentialToggle().text()).toBe('Turn Off'); }); - - describe('when clicking on the confidential toggle', () => { - it('emits updateConfidentialAttribute', () => { - findConfidentialToggle().trigger('click'); - - expect(eventHub.$emit).toHaveBeenCalledWith('updateConfidentialAttribute'); - }); - }); }); - describe('when confidentialApolloSidebar is turned on', () => { - const isConfidential = true; + describe('when succeeds', () => { + beforeEach(() => { + createComponent({ data: { isLoading: false }, props: { confidential: true } }); + findConfidentialToggle().trigger('click'); + }); - describe('when succeeds', () => { - beforeEach(() => { - createComponent({ data: { isLoading: false }, confidentialApolloSidebar: true }); - wrapper.vm.$store.state.noteableData.confidential = isConfidential; - findConfidentialToggle().trigger('click'); + it('dispatches the correct action', () => { + expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssuable', { + confidential: false, + fullPath: '', }); + }); - it('dispatches the correct action', () => { - expect(store.dispatch).toHaveBeenCalledWith('updateConfidentialityOnIssue', { - confidential: !isConfidential, - fullPath: '', - }); + it('resets loading', () => { + return waitForPromises().then(() => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); }); + }); - it('resets loading', () => { - return waitForPromises().then(() => { - expect(wrapper.find(GlLoadingIcon).exists()).toBe(false); - }); + it('emits close form', () => { + return waitForPromises().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm'); }); + }); - it('emits close form', () => { - return waitForPromises().then(() => { - expect(eventHub.$emit).toHaveBeenCalledWith('closeConfidentialityForm'); - }); + it('emits updateOnConfidentiality event', () => { + return waitForPromises().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('updateIssuableConfidentiality', false); }); }); + }); - describe('when fails', () => { - beforeEach(() => { - createComponent({ - data: { isLoading: false }, - confidentialApolloSidebar: true, - resolved: false, - }); - wrapper.vm.$store.state.noteableData.confidential = isConfidential; - findConfidentialToggle().trigger('click'); + describe('when fails', () => { + beforeEach(() => { + createComponent({ + data: { isLoading: false }, + props: { confidential: true }, + resolved: false, }); + findConfidentialToggle().trigger('click'); + }); - it('calls flash with the correct message', () => { - expect(flash).toHaveBeenCalledWith( - 'Something went wrong trying to change the confidentiality of this issue', - ); - }); + it('calls flash with the correct message', () => { + expect(flash).toHaveBeenCalledWith( + 'Something went wrong trying to change the confidentiality of this issue', + ); }); }); }); diff --git a/spec/frontend/sidebar/confidential/edit_form_spec.js b/spec/frontend/sidebar/confidential/edit_form_spec.js index a22bbe5ae0d..56f163eecd1 100644 --- a/spec/frontend/sidebar/confidential/edit_form_spec.js +++ b/spec/frontend/sidebar/confidential/edit_form_spec.js @@ -12,6 +12,7 @@ describe('Edit Form Dropdown', () => { ...props, isLoading: false, fullPath: '', + issuableType: 'issue', }, }); }; @@ -22,26 +23,26 @@ describe('Edit Form Dropdown', () => { }); describe('when not confidential', () => { - it('renders "You are going to turn off the confidentiality." in the ', () => { + it('renders "You are going to turn on the confidentiality." in the ', () => { createComponent({ - isConfidential: false, + confidential: false, toggleForm, updateConfidentialAttribute, }); - expect(wrapper.find('p').text()).toContain('You are going to turn on the confidentiality.'); + expect(wrapper.element).toMatchSnapshot(); }); }); describe('when confidential', () => { it('renders on or off text based on confidentiality', () => { createComponent({ - isConfidential: true, + confidential: true, toggleForm, updateConfidentialAttribute, }); - expect(wrapper.find('p').text()).toContain('You are going to turn off the confidentiality.'); + expect(wrapper.element).toMatchSnapshot(); }); }); }); diff --git a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js index 06cf1e6166c..bc2df9305d0 100644 --- a/spec/frontend/sidebar/confidential_issue_sidebar_spec.js +++ b/spec/frontend/sidebar/confidential_issue_sidebar_spec.js @@ -1,13 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import ConfidentialIssueSidebar from '~/sidebar/components/confidential/confidential_issue_sidebar.vue'; import EditForm from '~/sidebar/components/confidential/edit_form.vue'; -import SidebarService from '~/sidebar/services/sidebar_service'; -import createFlash from '~/flash'; -import RecaptchaModal from '~/vue_shared/components/recaptcha_modal.vue'; import createStore from '~/notes/stores'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import eventHub from '~/sidebar/event_hub'; +import * as types from '~/notes/stores/mutation_types'; jest.mock('~/flash'); jest.mock('~/sidebar/services/sidebar_service'); @@ -20,32 +17,14 @@ describe('Confidential Issue Sidebar Block', () => { .fn() .mockResolvedValue({ data: { issueSetConfidential: { issue: { confidential: true } } } }); - const findRecaptchaModal = () => wrapper.find(RecaptchaModal); - - const triggerUpdateConfidentialAttribute = () => { - wrapper.setData({ edit: true }); - return ( - // wait for edit form to become visible - wrapper.vm - .$nextTick() - .then(() => { - eventHub.$emit('updateConfidentialAttribute'); - }) - // wait for reCAPTCHA modal to render - .then(() => wrapper.vm.$nextTick()) - ); - }; - const createComponent = ({ propsData, data = {} }) => { const store = createStore(); - const service = new SidebarService(); wrapper = shallowMount(ConfidentialIssueSidebar, { store, data() { return data; }, propsData: { - service, iid: '', fullPath: '', ...propsData, @@ -133,61 +112,48 @@ describe('Confidential Issue Sidebar Block', () => { property: 'confidentiality', }); }); - - describe('for successful update', () => { - beforeEach(() => { - SidebarService.prototype.update.mockResolvedValue({ data: 'irrelevant' }); + }); + describe('computed confidential', () => { + beforeEach(() => { + createComponent({ + propsData: { + isEditable: true, + }, }); + }); - it('reloads the page', () => - triggerUpdateConfidentialAttribute().then(() => { - expect(window.location.reload).toHaveBeenCalled(); - })); + it('returns false when noteableData is not present', () => { + wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, null); - it('does not show an error message', () => - triggerUpdateConfidentialAttribute().then(() => { - expect(createFlash).not.toHaveBeenCalled(); - })); + expect(wrapper.vm.confidential).toBe(false); }); - describe('for update error', () => { - beforeEach(() => { - SidebarService.prototype.update.mockRejectedValue(new Error('updating failed!')); - }); - - it('does not reload the page', () => - triggerUpdateConfidentialAttribute().then(() => { - expect(window.location.reload).not.toHaveBeenCalled(); - })); + it('returns true when noteableData has confidential attr as true', () => { + wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); + wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true); - it('shows an error message', () => - triggerUpdateConfidentialAttribute().then(() => { - expect(createFlash).toHaveBeenCalled(); - })); + expect(wrapper.vm.confidential).toBe(true); }); - describe('for spam error', () => { - beforeEach(() => { - SidebarService.prototype.update.mockRejectedValue({ name: 'SpamError' }); - }); + it('returns false when noteableData has confidential attr as false', () => { + wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); + wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false); + + expect(wrapper.vm.confidential).toBe(false); + }); - it('does not reload the page', () => - triggerUpdateConfidentialAttribute().then(() => { - expect(window.location.reload).not.toHaveBeenCalled(); - })); + it('returns true when confidential attr is true', () => { + wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); + wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, true); - it('does not show an error message', () => - triggerUpdateConfidentialAttribute().then(() => { - expect(createFlash).not.toHaveBeenCalled(); - })); + expect(wrapper.vm.confidential).toBe(true); + }); - it('shows a reCAPTCHA modal', () => { - expect(findRecaptchaModal().exists()).toBe(false); + it('returns false when confidential attr is false', () => { + wrapper.vm.$store.commit(types.SET_NOTEABLE_DATA, {}); + wrapper.vm.$store.commit(types.SET_ISSUE_CONFIDENTIAL, false); - return triggerUpdateConfidentialAttribute().then(() => { - expect(findRecaptchaModal().exists()).toBe(true); - }); - }); + expect(wrapper.vm.confidential).toBe(false); }); }); }); diff --git a/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap new file mode 100644 index 00000000000..18d4df297df --- /dev/null +++ b/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap @@ -0,0 +1,79 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Edit Form Dropdown In issue page when locked the appropriate warning text is rendered 1`] = ` +<div + class="dropdown-menu sidebar-item-warning-message" + data-testid="warning-text" +> + <p + class="text" + > + <gl-sprintf-stub + message="Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment." + /> + </p> + + <edit-form-buttons-stub + islocked="true" + issuabledisplayname="issue" + /> +</div> +`; + +exports[`Edit Form Dropdown In issue page when unlocked the appropriate warning text is rendered 1`] = ` +<div + class="dropdown-menu sidebar-item-warning-message" + data-testid="warning-text" +> + <p + class="text" + > + <gl-sprintf-stub + message="Lock this %{issuableDisplayName}? Only %{strongStart}project members%{strongEnd} will be able to comment." + /> + </p> + + <edit-form-buttons-stub + issuabledisplayname="issue" + /> +</div> +`; + +exports[`Edit Form Dropdown In merge request page when locked the appropriate warning text is rendered 1`] = ` +<div + class="dropdown-menu sidebar-item-warning-message" + data-testid="warning-text" +> + <p + class="text" + > + <gl-sprintf-stub + message="Unlock this %{issuableDisplayName}? %{strongStart}Everyone%{strongEnd} will be able to comment." + /> + </p> + + <edit-form-buttons-stub + islocked="true" + issuabledisplayname="merge request" + /> +</div> +`; + +exports[`Edit Form Dropdown In merge request page when unlocked the appropriate warning text is rendered 1`] = ` +<div + class="dropdown-menu sidebar-item-warning-message" + data-testid="warning-text" +> + <p + class="text" + > + <gl-sprintf-stub + message="Lock this %{issuableDisplayName}? Only %{strongStart}project members%{strongEnd} will be able to comment." + /> + </p> + + <edit-form-buttons-stub + issuabledisplayname="merge request" + /> +</div> +`; diff --git a/spec/frontend/sidebar/lock/constants.js b/spec/frontend/sidebar/lock/constants.js new file mode 100644 index 00000000000..b9f08e9286d --- /dev/null +++ b/spec/frontend/sidebar/lock/constants.js @@ -0,0 +1,2 @@ +export const ISSUABLE_TYPE_ISSUE = 'issue'; +export const ISSUABLE_TYPE_MR = 'merge request'; diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js index 66f9237ce97..de1da3456f8 100644 --- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/lock/edit_form_buttons_spec.js @@ -1,31 +1,178 @@ import { shallowMount } from '@vue/test-utils'; +import { GlLoadingIcon } from '@gitlab/ui'; import EditFormButtons from '~/sidebar/components/lock/edit_form_buttons.vue'; +import eventHub from '~/sidebar/event_hub'; +import { deprecatedCreateFlash as flash } from '~/flash'; +import createStore from '~/notes/stores'; +import { createStore as createMrStore } from '~/mr_notes/stores'; +import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants'; + +jest.mock('~/sidebar/event_hub', () => ({ $emit: jest.fn() })); +jest.mock('~/flash'); describe('EditFormButtons', () => { let wrapper; + let store; + let issuableType; + let issuableDisplayName; + + const setIssuableType = pageType => { + issuableType = pageType; + issuableDisplayName = issuableType.replace(/_/g, ' '); + }; + + const findLockToggle = () => wrapper.find('[data-testid="lock-toggle"]'); + const findGlLoadingIcon = () => wrapper.find(GlLoadingIcon); - const mountComponent = propsData => shallowMount(EditFormButtons, { propsData }); + const createComponent = ({ props = {}, data = {}, resolved = true }) => { + store = issuableType === ISSUABLE_TYPE_ISSUE ? createStore() : createMrStore(); + + if (resolved) { + jest.spyOn(store, 'dispatch').mockResolvedValue(); + } else { + jest.spyOn(store, 'dispatch').mockRejectedValue(); + } + + wrapper = shallowMount(EditFormButtons, { + store, + provide: { + fullPath: '', + }, + propsData: { + isLocked: false, + issuableDisplayName, + ...props, + }, + data() { + return { + isLoading: false, + ...data, + }; + }, + }); + }; afterEach(() => { wrapper.destroy(); wrapper = null; }); - it('displays "Unlock" when locked', () => { - wrapper = mountComponent({ - isLocked: true, - updateLockedAttribute: () => {}, + describe.each` + pageType + ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} + `('In $pageType page', ({ pageType }) => { + beforeEach(() => { + setIssuableType(pageType); }); - expect(wrapper.text()).toContain('Unlock'); - }); + describe('when isLoading', () => { + beforeEach(() => { + createComponent({ data: { isLoading: true } }); + }); + + it('renders "Applying" in the toggle button', () => { + expect(findLockToggle().text()).toBe('Applying'); + }); + + it('disables the toggle button', () => { + expect(findLockToggle().attributes('disabled')).toBe('disabled'); + }); - it('displays "Lock" when unlocked', () => { - wrapper = mountComponent({ - isLocked: false, - updateLockedAttribute: () => {}, + it('displays the GlLoadingIcon', () => { + expect(findGlLoadingIcon().exists()).toBe(true); + }); }); - expect(wrapper.text()).toContain('Lock'); + describe.each` + isLocked | toggleText | statusText + ${false} | ${'Lock'} | ${'unlocked'} + ${true} | ${'Unlock'} | ${'locked'} + `('when $statusText', ({ isLocked, toggleText }) => { + beforeEach(() => { + createComponent({ + props: { + isLocked, + }, + }); + }); + + it(`toggle button displays "${toggleText}"`, () => { + expect(findLockToggle().text()).toContain(toggleText); + }); + + describe('when toggled', () => { + describe(`when resolved`, () => { + beforeEach(() => { + createComponent({ + props: { + isLocked, + }, + resolved: true, + }); + findLockToggle().trigger('click'); + }); + + it('dispatches the correct action', () => { + expect(store.dispatch).toHaveBeenCalledWith('updateLockedAttribute', { + locked: !isLocked, + fullPath: '', + }); + }); + + it('resets loading', async () => { + await wrapper.vm.$nextTick().then(() => { + expect(findGlLoadingIcon().exists()).toBe(false); + }); + }); + + it('emits close form', () => { + return wrapper.vm.$nextTick().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('closeLockForm'); + }); + }); + + it('does not flash an error message', () => { + expect(flash).not.toHaveBeenCalled(); + }); + }); + + describe(`when not resolved`, () => { + beforeEach(() => { + createComponent({ + props: { + isLocked, + }, + resolved: false, + }); + findLockToggle().trigger('click'); + }); + + it('dispatches the correct action', () => { + expect(store.dispatch).toHaveBeenCalledWith('updateLockedAttribute', { + locked: !isLocked, + fullPath: '', + }); + }); + + it('resets loading', async () => { + await wrapper.vm.$nextTick().then(() => { + expect(findGlLoadingIcon().exists()).toBe(false); + }); + }); + + it('emits close form', () => { + return wrapper.vm.$nextTick().then(() => { + expect(eventHub.$emit).toHaveBeenCalledWith('closeLockForm'); + }); + }); + + it('calls flash with the correct message', () => { + expect(flash).toHaveBeenCalledWith( + `Something went wrong trying to change the locked state of this ${issuableDisplayName}`, + ); + }); + }); + }); + }); }); }); diff --git a/spec/frontend/sidebar/lock/edit_form_spec.js b/spec/frontend/sidebar/lock/edit_form_spec.js index ec10a999a40..b1c3bfe3ef5 100644 --- a/spec/frontend/sidebar/lock/edit_form_spec.js +++ b/spec/frontend/sidebar/lock/edit_form_spec.js @@ -1,37 +1,54 @@ -import Vue from 'vue'; -import editForm from '~/sidebar/components/lock/edit_form.vue'; +import { shallowMount } from '@vue/test-utils'; +import EditForm from '~/sidebar/components/lock/edit_form.vue'; +import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants'; -describe('EditForm', () => { - let vm1; - let vm2; +describe('Edit Form Dropdown', () => { + let wrapper; + let issuableType; // Either ISSUABLE_TYPE_ISSUE or ISSUABLE_TYPE_MR + let issuableDisplayName; - beforeEach(() => { - const Component = Vue.extend(editForm); - const toggleForm = () => {}; - const updateLockedAttribute = () => {}; + const setIssuableType = pageType => { + issuableType = pageType; + issuableDisplayName = issuableType.replace(/_/g, ' '); + }; - vm1 = new Component({ - propsData: { - isLocked: true, - toggleForm, - updateLockedAttribute, - issuableType: 'issue', - }, - }).$mount(); + const findWarningText = () => wrapper.find('[data-testid="warning-text"]'); - vm2 = new Component({ + const createComponent = ({ props }) => { + wrapper = shallowMount(EditForm, { propsData: { isLocked: false, - toggleForm, - updateLockedAttribute, - issuableType: 'merge_request', + issuableDisplayName, + ...props, }, - }).$mount(); + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); - it('renders on the appropriate warning text', () => { - expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); + describe.each` + pageType + ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} + `('In $pageType page', ({ pageType }) => { + beforeEach(() => { + setIssuableType(pageType); + }); + + describe.each` + isLocked | lockStatusText + ${false} | ${'unlocked'} + ${true} | ${'locked'} + `('when $lockStatusText', ({ isLocked }) => { + beforeEach(() => { + createComponent({ props: { isLocked } }); + }); - expect(vm2.$el.innerHTML.includes('Lock this merge request?')).toBe(true); + it(`the appropriate warning text is rendered`, () => { + expect(findWarningText().element).toMatchSnapshot(); + }); + }); }); }); diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js new file mode 100644 index 00000000000..ab1423a9bbb --- /dev/null +++ b/spec/frontend/sidebar/lock/issuable_lock_form_spec.js @@ -0,0 +1,133 @@ +import { shallowMount } from '@vue/test-utils'; +import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; +import IssuableLockForm from '~/sidebar/components/lock/issuable_lock_form.vue'; +import EditForm from '~/sidebar/components/lock/edit_form.vue'; +import createStore from '~/notes/stores'; +import { createStore as createMrStore } from '~/mr_notes/stores'; +import { ISSUABLE_TYPE_ISSUE, ISSUABLE_TYPE_MR } from './constants'; + +describe('IssuableLockForm', () => { + let wrapper; + let store; + let issuableType; // Either ISSUABLE_TYPE_ISSUE or ISSUABLE_TYPE_MR + + const setIssuableType = pageType => { + issuableType = pageType; + }; + + const findSidebarCollapseIcon = () => wrapper.find('[data-testid="sidebar-collapse-icon"]'); + const findLockStatus = () => wrapper.find('[data-testid="lock-status"]'); + const findEditLink = () => wrapper.find('[data-testid="edit-link"]'); + const findEditForm = () => wrapper.find(EditForm); + + const initStore = isLocked => { + if (issuableType === ISSUABLE_TYPE_ISSUE) { + store = createStore(); + store.getters.getNoteableData.targetType = 'issue'; + } else { + store = createMrStore(); + } + store.getters.getNoteableData.discussion_locked = isLocked; + }; + + const createComponent = ({ props = {} }) => { + wrapper = shallowMount(IssuableLockForm, { + store, + propsData: { + isEditable: true, + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + pageType + ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} + `('In $pageType page', ({ pageType }) => { + beforeEach(() => { + setIssuableType(pageType); + }); + + describe.each` + isLocked + ${false} | ${true} + `(`renders for isLocked = $isLocked`, ({ isLocked }) => { + beforeEach(() => { + initStore(isLocked); + createComponent({}); + }); + + it('shows the lock status', () => { + expect(findLockStatus().text()).toBe(isLocked ? 'Locked' : 'Unlocked'); + }); + + describe('edit form', () => { + let isEditable; + beforeEach(() => { + isEditable = false; + createComponent({ props: { isEditable } }); + }); + + describe('when not editable', () => { + it('does not display the edit form when opened if not editable', () => { + expect(findEditForm().exists()).toBe(false); + findSidebarCollapseIcon().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findEditForm().exists()).toBe(false); + }); + }); + }); + + describe('when editable', () => { + beforeEach(() => { + isEditable = true; + createComponent({ props: { isEditable } }); + }); + + it('shows the editable status', () => { + expect(findEditLink().exists()).toBe(isEditable); + expect(findEditLink().text()).toBe('Edit'); + }); + + describe("when 'Edit' is clicked", () => { + it('displays the edit form when editable', () => { + expect(findEditForm().exists()).toBe(false); + findEditLink().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findEditForm().exists()).toBe(true); + }); + }); + + it('tracks the event ', () => { + const spy = mockTracking('_category_', wrapper.element, jest.spyOn); + triggerEvent(findEditLink().element); + + expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { + label: 'right_sidebar', + property: 'lock_issue', + }); + }); + }); + + describe('When sidebar is collapsed', () => { + it('displays the edit form when opened', () => { + expect(findEditForm().exists()).toBe(false); + findSidebarCollapseIcon().trigger('click'); + + return wrapper.vm.$nextTick().then(() => { + expect(findEditForm().exists()).toBe(true); + }); + }); + }); + }); + }); + }); + }); +}); diff --git a/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js b/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js deleted file mode 100644 index 00997326d87..00000000000 --- a/spec/frontend/sidebar/lock/lock_issue_sidebar_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -import Vue from 'vue'; -import { mockTracking, triggerEvent } from 'helpers/tracking_helper'; -import lockIssueSidebar from '~/sidebar/components/lock/lock_issue_sidebar.vue'; - -describe('LockIssueSidebar', () => { - let vm1; - let vm2; - - beforeEach(() => { - const Component = Vue.extend(lockIssueSidebar); - - const mediator = { - service: { - update: Promise.resolve(true), - }, - - store: { - isLockDialogOpen: false, - }, - }; - - vm1 = new Component({ - propsData: { - isLocked: true, - isEditable: true, - mediator, - issuableType: 'issue', - }, - }).$mount(); - - vm2 = new Component({ - propsData: { - isLocked: false, - isEditable: false, - mediator, - issuableType: 'merge_request', - }, - }).$mount(); - }); - - it('shows if locked and/or editable', () => { - expect(vm1.$el.innerHTML.includes('Edit')).toBe(true); - - expect(vm1.$el.innerHTML.includes('Locked')).toBe(true); - - expect(vm2.$el.innerHTML.includes('Unlocked')).toBe(true); - }); - - it('displays the edit form when editable', done => { - expect(vm1.isLockDialogOpen).toBe(false); - - vm1.$el.querySelector('.lock-edit').click(); - - expect(vm1.isLockDialogOpen).toBe(true); - - vm1.$nextTick(() => { - expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); - - done(); - }); - }); - - it('tracks an event when "Edit" is clicked', () => { - const spy = mockTracking('_category_', vm1.$el, jest.spyOn); - triggerEvent('.lock-edit'); - - expect(spy).toHaveBeenCalledWith('_category_', 'click_edit_button', { - label: 'right_sidebar', - property: 'lock_issue', - }); - }); - - it('displays the edit form when opened from collapsed state', done => { - expect(vm1.isLockDialogOpen).toBe(false); - - vm1.$el.querySelector('.sidebar-collapsed-icon').click(); - - expect(vm1.isLockDialogOpen).toBe(true); - - setImmediate(() => { - expect(vm1.$el.innerHTML.includes('Unlock this issue?')).toBe(true); - - done(); - }); - }); - - it('does not display the edit form when opened from collapsed state if not editable', done => { - expect(vm2.isLockDialogOpen).toBe(false); - - vm2.$el.querySelector('.sidebar-collapsed-icon').click(); - - Vue.nextTick() - .then(() => { - expect(vm2.isLockDialogOpen).toBe(false); - }) - .then(done) - .catch(done.fail); - }); -}); diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/todo_spec.js index 18b621cd12d..e56a78989eb 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/todo_spec.js @@ -36,7 +36,7 @@ describe('SidebarTodo', () => { it.each` isTodo | iconClass | label | icon - ${false} | ${''} | ${'Add a To Do'} | ${'todo-add'} + ${false} | ${''} | ${'Add a To-Do'} | ${'todo-add'} ${true} | ${'todo-undone'} | ${'Mark as done'} | ${'todo-done'} `( 'renders proper button when `isTodo` prop is `$isTodo`', diff --git a/spec/frontend/snippet/collapsible_input_spec.js b/spec/frontend/snippet/collapsible_input_spec.js index acd15164c95..aa017964437 100644 --- a/spec/frontend/snippet/collapsible_input_spec.js +++ b/spec/frontend/snippet/collapsible_input_spec.js @@ -1,5 +1,5 @@ -import setupCollapsibleInputs from '~/snippet/collapsible_input'; import { setHTMLFixture } from 'helpers/fixtures'; +import setupCollapsibleInputs from '~/snippet/collapsible_input'; describe('~/snippet/collapsible_input', () => { let formEl; diff --git a/spec/frontend/snippet/snippet_bundle_spec.js b/spec/frontend/snippet/snippet_bundle_spec.js index 38d05243c65..ad69a91fe89 100644 --- a/spec/frontend/snippet/snippet_bundle_spec.js +++ b/spec/frontend/snippet/snippet_bundle_spec.js @@ -1,6 +1,6 @@ +import { setHTMLFixture } from 'helpers/fixtures'; import Editor from '~/editor/editor_lite'; import initEditor from '~/snippet/snippet_bundle'; -import { setHTMLFixture } from 'helpers/fixtures'; jest.mock('~/editor/editor_lite', () => jest.fn()); diff --git a/spec/frontend/snippet/snippet_edit_spec.js b/spec/frontend/snippet/snippet_edit_spec.js index cfe5062c86b..7c12c0cac03 100644 --- a/spec/frontend/snippet/snippet_edit_spec.js +++ b/spec/frontend/snippet/snippet_edit_spec.js @@ -1,9 +1,8 @@ import '~/snippet/snippet_edit'; +import { triggerDOMEvent } from 'jest/helpers/dom_events_helper'; import { SnippetEditInit } from '~/snippets'; import initSnippet from '~/snippet/snippet_bundle'; -import { triggerDOMEvent } from 'jest/helpers/dom_events_helper'; - jest.mock('~/snippet/snippet_bundle'); jest.mock('~/snippets'); diff --git a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap index 959bc24eef6..1cf1ee74ddf 100644 --- a/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap +++ b/spec/frontend/snippets/components/__snapshots__/snippet_blob_edit_spec.js.snap @@ -1,25 +1,21 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`Snippet Blob Edit component rendering matches the snapshot 1`] = ` +exports[`Snippet Blob Edit component with loaded blob matches snapshot 1`] = ` <div - class="form-group file-editor" + class="file-holder snippet" > - <label> - File - </label> + <blob-header-edit-stub + candelete="true" + data-qa-selector="file_name_field" + id="blob_local_7_file_path" + value="foo/bar/test.md" + /> - <div - class="file-holder snippet" - > - <blob-header-edit-stub - data-qa-selector="file_name_field" - value="lorem.txt" - /> - - <blob-content-edit-stub - filename="lorem.txt" - value="Lorem ipsum dolor sit amet, consectetur adipiscing elit." - /> - </div> + <blob-content-edit-stub + fileglobalid="blob_local_7" + filename="foo/bar/test.md" + value="Lorem ipsum dolar sit amet, +consectetur adipiscing elit." + /> </div> `; 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 297ad16b681..6020d595e3f 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 @@ -60,7 +60,7 @@ exports[`Snippet Description Edit component rendering matches the snapshot 1`] = <a aria-label="Leave zen mode" - class="zen-control zen-control-leave js-zen-leave gl-text-gray-700" + class="zen-control zen-control-leave js-zen-leave gl-text-gray-500" href="#" > <icon-stub diff --git a/spec/frontend/snippets/components/edit_spec.js b/spec/frontend/snippets/components/edit_spec.js index d2265dfd506..980855a0615 100644 --- a/spec/frontend/snippets/components/edit_spec.js +++ b/spec/frontend/snippets/components/edit_spec.js @@ -1,134 +1,157 @@ -import { shallowMount } from '@vue/test-utils'; -import Flash from '~/flash'; - +import { ApolloMutation } from 'vue-apollo'; import { GlLoadingIcon } from '@gitlab/ui'; -import { redirectTo } from '~/lib/utils/url_utility'; - +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { deprecatedCreateFlash as Flash } from '~/flash'; +import * as urlUtils from '~/lib/utils/url_utility'; import SnippetEditApp from '~/snippets/components/edit.vue'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; -import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; +import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; import TitleField from '~/vue_shared/components/form/title.vue'; import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; -import { SNIPPET_CREATE_MUTATION_ERROR, SNIPPET_UPDATE_MUTATION_ERROR } from '~/snippets/constants'; - +import { SNIPPET_VISIBILITY_PRIVATE } from '~/snippets/constants'; import UpdateSnippetMutation from '~/snippets/mutations/updateSnippet.mutation.graphql'; import CreateSnippetMutation from '~/snippets/mutations/createSnippet.mutation.graphql'; - -import waitForPromises from 'helpers/wait_for_promises'; -import { ApolloMutation } from 'vue-apollo'; - -jest.mock('~/lib/utils/url_utility', () => ({ - redirectTo: jest.fn().mockName('redirectTo'), -})); +import { testEntries } from '../test_utils'; jest.mock('~/flash'); -let flashSpy; - -const rawProjectPathMock = '/project/path'; -const newlyEditedSnippetUrl = 'http://foo.bar'; -const apiError = { message: 'Ufff' }; -const mutationError = 'Bummer'; - -const attachedFilePath1 = 'foo/bar'; -const attachedFilePath2 = 'alpha/beta'; - -const actionWithContent = { - content: 'Foo Bar', -}; -const actionWithoutContent = { - content: '', -}; +const TEST_UPLOADED_FILES = ['foo/bar.txt', 'alpha/beta.js']; +const TEST_API_ERROR = 'Ufff'; +const TEST_MUTATION_ERROR = 'Bummer'; -const defaultProps = { - snippetGid: 'gid://gitlab/PersonalSnippet/42', - markdownPreviewPath: 'http://preview.foo.bar', - markdownDocsPath: 'http://docs.foo.bar', -}; -const defaultData = { - blobsActions: { - ...actionWithContent, - action: '', +const TEST_ACTIONS = { + NO_CONTENT: { + ...testEntries.created.diff, + content: '', + }, + NO_PATH: { + ...testEntries.created.diff, + filePath: '', + }, + VALID: { + ...testEntries.created.diff, }, }; +const TEST_WEB_URL = '/snippets/7'; + +const createTestSnippet = () => ({ + webUrl: TEST_WEB_URL, + id: 7, + title: 'Snippet Title', + description: 'Lorem ipsum snippet desc', + visibilityLevel: SNIPPET_VISIBILITY_PRIVATE, +}); + describe('Snippet Edit app', () => { let wrapper; - const resolveMutate = jest.fn().mockResolvedValue({ - data: { - updateSnippet: { - errors: [], - snippet: { - webUrl: newlyEditedSnippetUrl, + const mutationTypes = { + RESOLVE: jest.fn().mockResolvedValue({ + data: { + updateSnippet: { + errors: [], + snippet: createTestSnippet(), }, }, - }, - }); - - const resolveMutateWithErrors = jest.fn().mockResolvedValue({ - data: { - updateSnippet: { - errors: [mutationError], - snippet: { - webUrl: newlyEditedSnippetUrl, + }), + RESOLVE_WITH_ERRORS: jest.fn().mockResolvedValue({ + data: { + updateSnippet: { + errors: [TEST_MUTATION_ERROR], + snippet: createTestSnippet(), + }, + createSnippet: { + errors: [TEST_MUTATION_ERROR], + snippet: null, }, }, - createSnippet: { - errors: [mutationError], - snippet: null, - }, - }, - }); - - const rejectMutation = jest.fn().mockRejectedValue(apiError); - - const mutationTypes = { - RESOLVE: resolveMutate, - RESOLVE_WITH_ERRORS: resolveMutateWithErrors, - REJECT: rejectMutation, + }), + REJECT: jest.fn().mockRejectedValue(TEST_API_ERROR), }; function createComponent({ - props = defaultProps, - data = {}, + props = {}, loading = false, mutationRes = mutationTypes.RESOLVE, } = {}) { - const $apollo = { - queries: { - snippet: { - loading, - }, - }, - mutate: mutationRes, - }; + if (wrapper) { + throw new Error('wrapper already exists'); + } wrapper = shallowMount(SnippetEditApp, { - mocks: { $apollo }, + mocks: { + $apollo: { + queries: { + snippet: { loading }, + }, + mutate: mutationRes, + }, + }, stubs: { - FormFooterActions, ApolloMutation, + FormFooterActions, }, propsData: { + snippetGid: 'gid://gitlab/PersonalSnippet/42', + markdownPreviewPath: 'http://preview.foo.bar', + markdownDocsPath: 'http://docs.foo.bar', ...props, }, - data() { - return data; - }, }); - - flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure'); } + beforeEach(() => { + jest.spyOn(urlUtils, 'redirectTo').mockImplementation(); + }); + afterEach(() => { wrapper.destroy(); + wrapper = null; }); + const findBlobActions = () => wrapper.find(SnippetBlobActionsEdit); const findSubmitButton = () => wrapper.find('[data-testid="snippet-submit-btn"]'); - const findCancellButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); + const findCancelButton = () => wrapper.find('[data-testid="snippet-cancel-btn"]'); + const hasDisabledSubmit = () => Boolean(findSubmitButton().attributes('disabled')); + const clickSubmitBtn = () => wrapper.find('[data-testid="snippet-edit-form"]').trigger('submit'); + const triggerBlobActions = actions => findBlobActions().vm.$emit('actions', actions); + const setUploadFilesHtml = paths => { + wrapper.vm.$el.innerHTML = paths.map(path => `<input name="files[]" value="${path}">`).join(''); + }; + const getApiData = ({ + id, + title = '', + description = '', + visibilityLevel = SNIPPET_VISIBILITY_PRIVATE, + } = {}) => ({ + id, + title, + description, + visibilityLevel, + blobActions: [], + }); + + // Ideally we wouldn't call this method directly, but we don't have a way to trigger + // apollo responses yet. + const loadSnippet = (...edges) => { + if (edges.length) { + wrapper.setData({ + snippet: edges[0], + }); + } + + wrapper.vm.onSnippetFetch({ + data: { + snippets: { + edges, + }, + }, + }); + }; describe('rendering', () => { it('renders loader while the query is in flight', () => { @@ -136,295 +159,163 @@ describe('Snippet Edit app', () => { expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); }); - it('renders all required components', () => { - createComponent(); - - expect(wrapper.contains(TitleField)).toBe(true); - expect(wrapper.contains(SnippetDescriptionEdit)).toBe(true); - expect(wrapper.contains(SnippetBlobEdit)).toBe(true); - expect(wrapper.contains(SnippetVisibilityEdit)).toBe(true); - expect(wrapper.contains(FormFooterActions)).toBe(true); - }); - - it('does not fail if there is no snippet yet (new snippet creation)', () => { - const snippetGid = ''; - createComponent({ - props: { - ...defaultProps, - snippetGid, - }, - }); - - expect(wrapper.props('snippetGid')).toBe(snippetGid); - }); + it.each([[{}], [{ snippetGid: '' }]])( + 'should render all required components with %s', + props => { + createComponent(props); - it.each` - title | blobsActions | expectation - ${''} | ${{}} | ${true} - ${''} | ${{ actionWithContent }} | ${true} - ${''} | ${{ actionWithoutContent }} | ${true} - ${'foo'} | ${{}} | ${true} - ${'foo'} | ${{ actionWithoutContent }} | ${true} - ${'foo'} | ${{ actionWithoutContent, actionWithContent }} | ${true} - ${'foo'} | ${{ actionWithContent }} | ${false} - `( - 'disables submit button unless both title and content for all blobs are present', - ({ title, blobsActions, expectation }) => { - createComponent({ - data: { - snippet: { title }, - blobsActions, - }, - }); - const isBtnDisabled = Boolean(findSubmitButton().attributes('disabled')); - expect(isBtnDisabled).toBe(expectation); + 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(findBlobActions().exists()).toBe(true); }, ); it.each` - isNew | status | expectation - ${true} | ${`new`} | ${`/snippets`} - ${false} | ${`existing`} | ${newlyEditedSnippetUrl} - `('sets correct href for the cancel button on a $status snippet', ({ isNew, expectation }) => { - createComponent({ - data: { - snippet: { webUrl: newlyEditedSnippetUrl }, - newSnippet: isNew, - }, - }); + title | actions | shouldDisable + ${''} | ${[]} | ${true} + ${''} | ${[TEST_ACTIONS.VALID]} | ${true} + ${'foo'} | ${[]} | ${false} + ${'foo'} | ${[TEST_ACTIONS.VALID]} | ${false} + ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_CONTENT]} | ${true} + ${'foo'} | ${[TEST_ACTIONS.VALID, TEST_ACTIONS.NO_PATH]} | ${true} + `( + 'should handle submit disable (title=$title, actions=$actions, shouldDisable=$shouldDisable)', + async ({ title, actions, shouldDisable }) => { + createComponent(); - expect(findCancellButton().attributes('href')).toBe(expectation); - }); - }); + loadSnippet({ title }); + triggerBlobActions(actions); - describe('functionality', () => { - describe('form submission handling', () => { - it('does not submit unchanged blobs', () => { - const foo = { - action: '', - }; - const bar = { - action: 'update', - }; - createComponent({ - data: { - blobsActions: { - foo, - bar, - }, - }, - }); - clickSubmitBtn(); + await wrapper.vm.$nextTick(); - return waitForPromises().then(() => { - expect(resolveMutate).toHaveBeenCalledWith( - expect.objectContaining({ variables: { input: { files: [bar] } } }), - ); - }); - }); + expect(hasDisabledSubmit()).toBe(shouldDisable); + }, + ); - it.each` - newSnippet | projectPath | mutation | mutationName - ${true} | ${rawProjectPathMock} | ${CreateSnippetMutation} | ${'CreateSnippetMutation with projectPath'} - ${true} | ${''} | ${CreateSnippetMutation} | ${'CreateSnippetMutation without projectPath'} - ${false} | ${rawProjectPathMock} | ${UpdateSnippetMutation} | ${'UpdateSnippetMutation with projectPath'} - ${false} | ${''} | ${UpdateSnippetMutation} | ${'UpdateSnippetMutation without projectPath'} - `('should submit $mutationName correctly', ({ newSnippet, projectPath, mutation }) => { + it.each` + projectPath | snippetArg | expectation + ${''} | ${[]} | ${'/-/snippets'} + ${'project/path'} | ${[]} | ${'/project/path/-/snippets'} + ${''} | ${[createTestSnippet()]} | ${TEST_WEB_URL} + ${'project/path'} | ${[createTestSnippet()]} | ${TEST_WEB_URL} + `( + 'should set cancel href when (projectPath=$projectPath, snippet=$snippetArg)', + async ({ projectPath, snippetArg, expectation }) => { createComponent({ - data: { - newSnippet, - ...defaultData, - }, - props: { - ...defaultProps, - projectPath, - }, + props: { projectPath }, }); - const mutationPayload = { - mutation, - variables: { - input: newSnippet ? expect.objectContaining({ projectPath }) : expect.any(Object), - }, - }; - - clickSubmitBtn(); - - expect(resolveMutate).toHaveBeenCalledWith(mutationPayload); - }); + loadSnippet(...snippetArg); - it('redirects to snippet view on successful mutation', () => { - createComponent(); - clickSubmitBtn(); + await wrapper.vm.$nextTick(); - return waitForPromises().then(() => { - expect(redirectTo).toHaveBeenCalledWith(newlyEditedSnippetUrl); - }); - }); + expect(findCancelButton().attributes('href')).toBe(expectation); + }, + ); + }); + describe('functionality', () => { + describe('form submission handling', () => { it.each` - newSnippet | projectPath | mutationName - ${true} | ${rawProjectPathMock} | ${'CreateSnippetMutation with projectPath'} - ${true} | ${''} | ${'CreateSnippetMutation without projectPath'} - ${false} | ${rawProjectPathMock} | ${'UpdateSnippetMutation with projectPath'} - ${false} | ${''} | ${'UpdateSnippetMutation without projectPath'} + snippetArg | projectPath | uploadedFiles | input | mutation + ${[]} | ${'project/path'} | ${[]} | ${{ ...getApiData(), projectPath: 'project/path', uploadedFiles: [] }} | ${CreateSnippetMutation} + ${[]} | ${''} | ${[]} | ${{ ...getApiData(), projectPath: '', uploadedFiles: [] }} | ${CreateSnippetMutation} + ${[]} | ${''} | ${TEST_UPLOADED_FILES} | ${{ ...getApiData(), projectPath: '', uploadedFiles: TEST_UPLOADED_FILES }} | ${CreateSnippetMutation} + ${[createTestSnippet()]} | ${'project/path'} | ${[]} | ${getApiData(createTestSnippet())} | ${UpdateSnippetMutation} + ${[createTestSnippet()]} | ${''} | ${[]} | ${getApiData(createTestSnippet())} | ${UpdateSnippetMutation} `( - 'does not redirect to snippet view if the seemingly successful' + - ' $mutationName response contains errors', - ({ newSnippet, projectPath }) => { + 'should submit mutation with (snippet=$snippetArg, projectPath=$projectPath, uploadedFiles=$uploadedFiles)', + async ({ snippetArg, projectPath, uploadedFiles, mutation, input }) => { createComponent({ - data: { - newSnippet, - }, props: { - ...defaultProps, projectPath, }, - mutationRes: mutationTypes.RESOLVE_WITH_ERRORS, }); + loadSnippet(...snippetArg); + setUploadFilesHtml(uploadedFiles); + + await wrapper.vm.$nextTick(); clickSubmitBtn(); - return waitForPromises().then(() => { - expect(redirectTo).not.toHaveBeenCalled(); - expect(flashSpy).toHaveBeenCalledWith(mutationError); + expect(mutationTypes.RESOLVE).toHaveBeenCalledWith({ + mutation, + variables: { + input, + }, }); }, ); - it('flashes an error if mutation failed', () => { - createComponent({ - mutationRes: mutationTypes.REJECT, - }); + it('should redirect to snippet view on successful mutation', async () => { + createComponent(); + loadSnippet(createTestSnippet()); clickSubmitBtn(); - return waitForPromises().then(() => { - expect(redirectTo).not.toHaveBeenCalled(); - expect(flashSpy).toHaveBeenCalledWith(apiError); - }); + await waitForPromises(); + + expect(urlUtils.redirectTo).toHaveBeenCalledWith(TEST_WEB_URL); }); it.each` - isNew | status | expectation - ${true} | ${`new`} | ${SNIPPET_CREATE_MUTATION_ERROR.replace('%{err}', '')} - ${false} | ${`existing`} | ${SNIPPET_UPDATE_MUTATION_ERROR.replace('%{err}', '')} + snippetArg | projectPath | mutationRes | expectMessage + ${[]} | ${'project/path'} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} + ${[]} | ${''} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't create snippet: ${TEST_MUTATION_ERROR}`} + ${[]} | ${''} | ${mutationTypes.REJECT} | ${`Can't create snippet: ${TEST_API_ERROR}`} + ${[createTestSnippet()]} | ${'project/path'} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} + ${[createTestSnippet()]} | ${''} | ${mutationTypes.RESOLVE_WITH_ERRORS} | ${`Can't update snippet: ${TEST_MUTATION_ERROR}`} `( - `renders the correct error message if mutation fails for $status snippet`, - ({ isNew, expectation }) => { + 'should flash error with (snippet=$snippetArg, projectPath=$projectPath)', + async ({ snippetArg, projectPath, mutationRes, expectMessage }) => { createComponent({ - data: { - newSnippet: isNew, + props: { + projectPath, }, - mutationRes: mutationTypes.REJECT, + mutationRes, }); + loadSnippet(...snippetArg); clickSubmitBtn(); - return waitForPromises().then(() => { - expect(Flash).toHaveBeenCalledWith(expect.stringContaining(expectation)); - }); + await waitForPromises(); + + expect(urlUtils.redirectTo).not.toHaveBeenCalled(); + expect(Flash).toHaveBeenCalledWith(expectMessage); }, ); }); - describe('correctly includes attached files into the mutation', () => { - const createMutationPayload = expectation => { - return expect.objectContaining({ - variables: { - input: expect.objectContaining({ uploadedFiles: expectation }), - }, - }); - }; - - const updateMutationPayload = () => { - return expect.objectContaining({ - variables: { - input: expect.not.objectContaining({ uploadedFiles: expect.anything() }), - }, - }); - }; - - it.each` - paths | expectation - ${[attachedFilePath1]} | ${[attachedFilePath1]} - ${[attachedFilePath1, attachedFilePath2]} | ${[attachedFilePath1, attachedFilePath2]} - ${[]} | ${[]} - `(`correctly sends paths for $paths.length files`, ({ paths, expectation }) => { - createComponent({ - data: { - newSnippet: true, - }, - }); - - const fixtures = paths.map(path => { - return path ? `<input name="files[]" value="${path}">` : undefined; - }); - wrapper.vm.$el.innerHTML += fixtures.join(''); - - clickSubmitBtn(); - - expect(resolveMutate).toHaveBeenCalledWith(createMutationPayload(expectation)); - }); - - it(`neither fails nor sends 'uploadedFiles' to update mutation`, () => { - createComponent(); - - clickSubmitBtn(); - expect(resolveMutate).toHaveBeenCalledWith(updateMutationPayload()); - }); - }); - describe('on before unload', () => { - let event; - let returnValueSetter; - - const bootstrap = data => { - createComponent({ - data, - }); - - event = new Event('beforeunload'); - returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); - }; - - it('does not prevent page navigation if there are no blobs', () => { - bootstrap(); - window.dispatchEvent(event); - - expect(returnValueSetter).not.toHaveBeenCalled(); - }); - - it('does not prevent page navigation if there are no changes to the blobs content', () => { - bootstrap({ - blobsActions: { - foo: { - ...actionWithContent, - action: '', - }, - }, - }); - window.dispatchEvent(event); + it.each` + condition | expectPrevented | action + ${'there are no actions'} | ${false} | ${() => triggerBlobActions([])} + ${'there are actions'} | ${true} | ${() => triggerBlobActions([testEntries.updated.diff])} + ${'the snippet is being saved'} | ${false} | ${() => clickSubmitBtn()} + `( + 'handles before unload prevent when $condition (expectPrevented=$expectPrevented)', + ({ expectPrevented, action }) => { + createComponent(); + loadSnippet(); - expect(returnValueSetter).not.toHaveBeenCalled(); - }); + action(); - it('prevents page navigation if there are some changes in the snippet content', () => { - bootstrap({ - blobsActions: { - foo: { - ...actionWithContent, - action: 'update', - }, - }, - }); + const event = new Event('beforeunload'); + const returnValueSetter = jest.spyOn(event, 'returnValue', 'set'); - window.dispatchEvent(event); + window.dispatchEvent(event); - expect(returnValueSetter).toHaveBeenCalledWith( - 'Are you sure you want to lose unsaved changes?', - ); - }); + if (expectPrevented) { + expect(returnValueSetter).toHaveBeenCalledWith( + 'Are you sure you want to lose unsaved changes?', + ); + } else { + expect(returnValueSetter).not.toHaveBeenCalled(); + } + }, + ); }); }); }); diff --git a/spec/frontend/snippets/components/show_spec.js b/spec/frontend/snippets/components/show_spec.js index b5446e70028..8cccbb83d54 100644 --- a/spec/frontend/snippets/components/show_spec.js +++ b/spec/frontend/snippets/components/show_spec.js @@ -1,19 +1,27 @@ +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 SnippetHeader from '~/snippets/components/snippet_header.vue'; import SnippetTitle from '~/snippets/components/snippet_title.vue'; import SnippetBlob from '~/snippets/components/snippet_blob_view.vue'; -import { GlLoadingIcon } from '@gitlab/ui'; -import { Blob, BinaryBlob } from 'jest/blob/components/mock_data'; +import CloneDropdownButton from '~/vue_shared/components/clone_dropdown.vue'; -import { shallowMount } from '@vue/test-utils'; -import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; +import { + SNIPPET_VISIBILITY_INTERNAL, + SNIPPET_VISIBILITY_PRIVATE, + SNIPPET_VISIBILITY_PUBLIC, +} from '~/snippets/constants'; describe('Snippet view app', () => { let wrapper; const defaultProps = { snippetGid: 'gid://gitlab/PersonalSnippet/42', }; + const webUrl = 'http://foo.bar'; + const dummyHTTPUrl = webUrl; + const dummySSHUrl = 'ssh://foo.bar'; function createComponent({ props = defaultProps, data = {}, loading = false } = {}) { const $apollo = { @@ -72,4 +80,47 @@ describe('Snippet view app', () => { expect(blobs.at(0).props('blob')).toEqual(Blob); expect(blobs.at(1).props('blob')).toEqual(BinaryBlob); }); + + describe('Embed dropdown rendering', () => { + it.each` + visibilityLevel | condition | isRendered + ${SNIPPET_VISIBILITY_INTERNAL} | ${'not render'} | ${false} + ${SNIPPET_VISIBILITY_PRIVATE} | ${'not render'} | ${false} + ${'foo'} | ${'not render'} | ${false} + ${SNIPPET_VISIBILITY_PUBLIC} | ${'render'} | ${true} + `('does $condition blob-embeddable by default', ({ visibilityLevel, isRendered }) => { + createComponent({ + data: { + snippet: { + visibilityLevel, + webUrl, + }, + }, + }); + expect(wrapper.contains(BlobEmbeddable)).toBe(isRendered); + }); + }); + + describe('Clone button rendering', () => { + it.each` + httpUrlToRepo | sshUrlToRepo | shouldRender | isRendered + ${null} | ${null} | ${'Should not'} | ${false} + ${null} | ${dummySSHUrl} | ${'Should'} | ${true} + ${dummyHTTPUrl} | ${null} | ${'Should'} | ${true} + ${dummyHTTPUrl} | ${dummySSHUrl} | ${'Should'} | ${true} + `( + '$shouldRender render "Clone" button when `httpUrlToRepo` is $httpUrlToRepo and `sshUrlToRepo` is $sshUrlToRepo', + ({ httpUrlToRepo, sshUrlToRepo, isRendered }) => { + createComponent({ + data: { + snippet: { + sshUrlToRepo, + httpUrlToRepo, + }, + }, + }); + expect(wrapper.contains(CloneDropdownButton)).toBe(isRendered); + }, + ); + }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js new file mode 100644 index 00000000000..8b2051008d7 --- /dev/null +++ b/spec/frontend/snippets/components/snippet_blob_actions_edit_spec.js @@ -0,0 +1,301 @@ +import { times } from 'lodash'; +import { shallowMount } from '@vue/test-utils'; +import SnippetBlobActionsEdit from '~/snippets/components/snippet_blob_actions_edit.vue'; +import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; +import { + SNIPPET_MAX_BLOBS, + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_MOVE, +} from '~/snippets/constants'; +import { testEntries, createBlobFromTestEntry } from '../test_utils'; + +const TEST_BLOBS = [ + createBlobFromTestEntry(testEntries.updated), + createBlobFromTestEntry(testEntries.deleted), +]; + +const TEST_BLOBS_UNLOADED = TEST_BLOBS.map(blob => ({ ...blob, content: '', isLoaded: false })); + +describe('snippets/components/snippet_blob_actions_edit', () => { + let wrapper; + + const createComponent = (props = {}, snippetMultipleFiles = true) => { + wrapper = shallowMount(SnippetBlobActionsEdit, { + propsData: { + initBlobs: TEST_BLOBS, + ...props, + }, + provide: { + glFeatures: { + snippetMultipleFiles, + }, + }, + }); + }; + + const findLabel = () => wrapper.find('label'); + const findBlobEdits = () => wrapper.findAll(SnippetBlobEdit); + const findBlobsData = () => + findBlobEdits().wrappers.map(x => ({ + blob: x.props('blob'), + classes: x.classes(), + })); + const findFirstBlobEdit = () => findBlobEdits().at(0); + const findAddButton = () => wrapper.find('[data-testid="add_button"]'); + const getLastActions = () => { + const events = wrapper.emitted().actions; + + return events[events.length - 1]?.[0]; + }; + const buildBlobsDataExpectation = blobs => + blobs.map((blob, index) => ({ + blob: { + ...blob, + id: expect.stringMatching('blob_local_'), + }, + classes: index > 0 ? ['gl-mt-3'] : [], + })); + const triggerBlobDelete = idx => + findBlobEdits() + .at(idx) + .vm.$emit('delete'); + const triggerBlobUpdate = (idx, props) => + findBlobEdits() + .at(idx) + .vm.$emit('blob-updated', props); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + featureFlag | label | showDelete | showAdd + ${true} | ${'Files'} | ${true} | ${true} + ${false} | ${'File'} | ${false} | ${false} + `('with feature flag = $featureFlag', ({ featureFlag, label, showDelete, showAdd }) => { + beforeEach(() => { + createComponent({}, featureFlag); + }); + + it('renders label', () => { + expect(findLabel().text()).toBe(label); + }); + + it(`renders delete button (show=${showDelete})`, () => { + expect(findFirstBlobEdit().props()).toMatchObject({ + showDelete, + canDelete: true, + }); + }); + + it(`renders add button (show=${showAdd})`, () => { + expect(findAddButton().exists()).toBe(showAdd); + }); + }); + + describe('with default', () => { + beforeEach(() => { + createComponent(); + }); + + it('emits no actions', () => { + expect(getLastActions()).toEqual([]); + }); + + it('shows blobs', () => { + expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED)); + }); + + it('shows add button', () => { + const button = findAddButton(); + + expect(button.text()).toBe(`Add another file ${TEST_BLOBS.length}/${SNIPPET_MAX_BLOBS}`); + expect(button.props('disabled')).toBe(false); + }); + + describe('when add is clicked', () => { + beforeEach(() => { + findAddButton().vm.$emit('click'); + }); + + it('adds blob with empty content', () => { + expect(findBlobsData()).toEqual( + buildBlobsDataExpectation([ + ...TEST_BLOBS_UNLOADED, + { + content: '', + isLoaded: true, + path: '', + }, + ]), + ); + }); + + it('emits action', () => { + expect(getLastActions()).toEqual([ + expect.objectContaining({ + action: SNIPPET_BLOB_ACTION_CREATE, + }), + ]); + }); + }); + + describe('when blob is deleted', () => { + beforeEach(() => { + triggerBlobDelete(1); + }); + + it('removes blob', () => { + expect(findBlobsData()).toEqual(buildBlobsDataExpectation(TEST_BLOBS_UNLOADED.slice(0, 1))); + }); + + it('emits action', () => { + expect(getLastActions()).toEqual([ + expect.objectContaining({ + ...testEntries.deleted.diff, + content: '', + }), + ]); + }); + }); + + describe('when blob changes path', () => { + beforeEach(() => { + triggerBlobUpdate(0, { path: 'new/path' }); + }); + + it('renames blob', () => { + expect(findBlobsData()[0]).toMatchObject({ + blob: { + path: 'new/path', + }, + }); + }); + + it('emits action', () => { + expect(getLastActions()).toMatchObject([ + { + action: SNIPPET_BLOB_ACTION_MOVE, + filePath: 'new/path', + previousPath: testEntries.updated.diff.filePath, + }, + ]); + }); + }); + + describe('when blob emits new content', () => { + const { content } = testEntries.updated.diff; + const originalContent = `${content}\noriginal content\n`; + + beforeEach(() => { + triggerBlobUpdate(0, { content: originalContent }); + }); + + it('loads new content', () => { + expect(findBlobsData()[0]).toMatchObject({ + blob: { + content: originalContent, + isLoaded: true, + }, + }); + }); + + it('does not emit an action', () => { + expect(getLastActions()).toEqual([]); + }); + + it('emits an action when content changes again', async () => { + triggerBlobUpdate(0, { content }); + + await wrapper.vm.$nextTick(); + + expect(getLastActions()).toEqual([testEntries.updated.diff]); + }); + }); + }); + + describe('with 1 blob', () => { + beforeEach(() => { + createComponent({ initBlobs: [createBlobFromTestEntry(testEntries.created)] }); + }); + + it('disables delete button', () => { + expect(findBlobEdits()).toHaveLength(1); + expect( + findBlobEdits() + .at(0) + .props(), + ).toMatchObject({ + showDelete: true, + canDelete: false, + }); + }); + + describe(`when added ${SNIPPET_MAX_BLOBS} files`, () => { + let addButton; + + beforeEach(() => { + addButton = findAddButton(); + + times(SNIPPET_MAX_BLOBS - 1, () => addButton.vm.$emit('click')); + }); + + it('should have blobs', () => { + expect(findBlobsData()).toHaveLength(SNIPPET_MAX_BLOBS); + }); + + it('should disable add button', () => { + expect(addButton.props('disabled')).toBe(true); + }); + }); + }); + + describe('with 0 init blob', () => { + beforeEach(() => { + createComponent({ initBlobs: [] }); + }); + + it('shows 1 blob by default', () => { + expect(findBlobsData()).toEqual([ + expect.objectContaining({ + blob: { + id: expect.stringMatching('blob_local_'), + content: '', + path: '', + isLoaded: true, + }, + }), + ]); + }); + + it('emits create action', () => { + expect(getLastActions()).toEqual([ + { + action: SNIPPET_BLOB_ACTION_CREATE, + content: '', + filePath: '', + previousPath: '', + }, + ]); + }); + }); + + describe(`with ${SNIPPET_MAX_BLOBS} files`, () => { + beforeEach(() => { + const initBlobs = Array(SNIPPET_MAX_BLOBS) + .fill(1) + .map(() => createBlobFromTestEntry(testEntries.created)); + + createComponent({ initBlobs }); + }); + + it('should have blobs', () => { + expect(findBlobsData()).toHaveLength(SNIPPET_MAX_BLOBS); + }); + + it('should disable add button', () => { + expect(findAddButton().props('disabled')).toBe(true); + }); + }); +}); diff --git a/spec/frontend/snippets/components/snippet_blob_edit_spec.js b/spec/frontend/snippets/components/snippet_blob_edit_spec.js index 009074b4558..188f9ae5cf1 100644 --- a/spec/frontend/snippets/components/snippet_blob_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_edit_spec.js @@ -1,165 +1,168 @@ -import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; -import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; -import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import { GlLoadingIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { TEST_HOST } from 'helpers/test_constants'; +import SnippetBlobEdit from '~/snippets/components/snippet_blob_edit.vue'; +import BlobHeaderEdit from '~/blob/components/blob_edit_header.vue'; +import BlobContentEdit from '~/blob/components/blob_edit_content.vue'; import axios from '~/lib/utils/axios_utils'; import { joinPaths } from '~/lib/utils/url_utility'; -import waitForPromises from 'helpers/wait_for_promises'; - -jest.mock('~/blob/utils', () => jest.fn()); - -jest.mock('~/lib/utils/url_utility', () => ({ - getBaseURL: jest.fn().mockReturnValue('foo/'), - joinPaths: jest - .fn() - .mockName('joinPaths') - .mockReturnValue('contentApiURL'), -})); +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); -let flashSpy; +const TEST_ID = 'blob_local_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_BLOB = { + id: TEST_ID, + rawPath: TEST_RAW_PATH, + path: TEST_PATH, + content: '', + isLoaded: false, +}; + +const TEST_BLOB_LOADED = { + ...TEST_BLOB, + content: TEST_CONTENT, + isLoaded: true, +}; describe('Snippet Blob Edit component', () => { let wrapper; let axiosMock; - const contentMock = 'Lorem ipsum dolor sit amet, consectetur adipiscing elit.'; - const pathMock = 'lorem.txt'; - const rawPathMock = 'foo/bar'; - const blob = { - path: pathMock, - content: contentMock, - rawPath: rawPathMock, - }; - const findComponent = component => wrapper.find(component); - function createComponent(props = {}, data = { isContentLoading: false }) { + const createComponent = (props = {}) => { wrapper = shallowMount(SnippetBlobEdit, { propsData: { + blob: TEST_BLOB, ...props, }, - data() { - return { - ...data, - }; - }, }); - flashSpy = jest.spyOn(wrapper.vm, 'flashAPIFailure'); - } + }; + + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + const findHeader = () => wrapper.find(BlobHeaderEdit); + const findContent = () => wrapper.find(BlobContentEdit); + const getLastUpdatedArgs = () => { + const event = wrapper.emitted()['blob-updated']; + + return event?.[event.length - 1][0]; + }; beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); - createComponent(); + axiosMock.onGet(TEST_FULL_PATH).reply(200, TEST_CONTENT); }); afterEach(() => { - axiosMock.restore(); wrapper.destroy(); + wrapper = null; + axiosMock.restore(); }); - describe('rendering', () => { - it('matches the snapshot', () => { - createComponent({ blob }); - expect(wrapper.element).toMatchSnapshot(); + describe('with not loaded blob', () => { + beforeEach(async () => { + createComponent(); }); - it('renders required components', () => { - expect(findComponent(BlobHeaderEdit).exists()).toBe(true); - expect(findComponent(BlobContentEdit).exists()).toBe(true); + it('shows blob header', () => { + expect(findHeader().props()).toMatchObject({ + value: TEST_BLOB.path, + }); + expect(findHeader().attributes('id')).toBe(`${TEST_ID}_file_path`); }); - it('renders loader if existing blob is supplied but no content is fetched yet', () => { - createComponent({ blob }, { isContentLoading: true }); - expect(wrapper.contains(GlLoadingIcon)).toBe(true); - expect(findComponent(BlobContentEdit).exists()).toBe(false); + it('emits delete when deleted', () => { + expect(wrapper.emitted().delete).toBeUndefined(); + + findHeader().vm.$emit('delete'); + + expect(wrapper.emitted().delete).toHaveLength(1); }); - it('does not render loader if when blob is not supplied', () => { - createComponent(); - expect(wrapper.contains(GlLoadingIcon)).toBe(false); - expect(findComponent(BlobContentEdit).exists()).toBe(true); + it('emits update when path changes', () => { + const newPath = 'new/path.md'; + + findHeader().vm.$emit('input', newPath); + + expect(getLastUpdatedArgs()).toEqual({ path: newPath }); }); - }); - describe('functionality', () => { - it('does not fail without blob', () => { - const spy = jest.spyOn(global.console, 'error'); - createComponent({ blob: undefined }); + it('emits update when content is loaded', async () => { + await waitForPromises(); - expect(spy).not.toHaveBeenCalled(); - expect(findComponent(BlobContentEdit).exists()).toBe(true); + expect(getLastUpdatedArgs()).toEqual({ content: TEST_CONTENT }); }); + }); - it.each` - emitter | prop - ${BlobHeaderEdit} | ${'filePath'} - ${BlobContentEdit} | ${'content'} - `('emits "blob-updated" event when the $prop gets changed', ({ emitter, prop }) => { - expect(wrapper.emitted('blob-updated')).toBeUndefined(); - const newValue = 'foo.bar'; - findComponent(emitter).vm.$emit('input', newValue); - - return nextTick().then(() => { - expect(wrapper.emitted('blob-updated')[0]).toEqual([ - expect.objectContaining({ - [prop]: newValue, - }), - ]); - }); + describe('with error', () => { + beforeEach(() => { + axiosMock.reset(); + axiosMock.onGet(TEST_FULL_PATH).replyOnce(500); + createComponent(); }); - describe('fetching blob content', () => { - const bootstrapForExistingSnippet = resp => { - createComponent({ - blob: { - ...blob, - content: '', - }, - }); + it('should call flash', async () => { + await waitForPromises(); - if (resp === 500) { - axiosMock.onGet('contentApiURL').reply(500); - } else { - axiosMock.onGet('contentApiURL').reply(200, contentMock); - } - }; + expect(createFlash).toHaveBeenCalledWith( + "Can't fetch content for the blob: Error: Request failed with status code 500", + ); + }); + }); - const bootstrapForNewSnippet = () => { - createComponent(); - }; + describe('with loaded blob', () => { + beforeEach(() => { + createComponent({ blob: TEST_BLOB_LOADED }); + }); - it('fetches blob content with the additional query', () => { - bootstrapForExistingSnippet(); + it('matches snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); - return waitForPromises().then(() => { - expect(joinPaths).toHaveBeenCalledWith('foo/', rawPathMock); - expect(findComponent(BlobHeaderEdit).props('value')).toBe(pathMock); - expect(findComponent(BlobContentEdit).props('value')).toBe(contentMock); - }); - }); + it('does not make API request', () => { + expect(axiosMock.history.get).toHaveLength(0); + }); + }); - it('flashes the error message if fetching content fails', () => { - bootstrapForExistingSnippet(500); + describe.each` + props | showLoading | showContent + ${{ blob: TEST_BLOB, canDelete: true, showDelete: true }} | ${true} | ${false} + ${{ blob: TEST_BLOB, canDelete: false, showDelete: false }} | ${true} | ${false} + ${{ blob: TEST_BLOB_LOADED }} | ${false} | ${true} + `('with $props', ({ props, showLoading, showContent }) => { + beforeEach(() => { + createComponent(props); + }); - return waitForPromises().then(() => { - expect(flashSpy).toHaveBeenCalled(); - expect(findComponent(BlobContentEdit).props('value')).toBe(''); - }); + it('shows blob header', () => { + const { canDelete = true, showDelete = false } = props; + + expect(findHeader().props()).toMatchObject({ + canDelete, + showDelete, }); + }); - it('does not fetch content for new snippet', () => { - bootstrapForNewSnippet(); + it(`handles loading icon (show=${showLoading})`, () => { + expect(findLoadingIcon().exists()).toBe(showLoading); + }); - return waitForPromises().then(() => { - // we keep using waitForPromises to make sure we do not run failed test - expect(findComponent(BlobHeaderEdit).props('value')).toBe(''); - expect(findComponent(BlobContentEdit).props('value')).toBe(''); - expect(joinPaths).not.toHaveBeenCalled(); + it(`handles content (show=${showContent})`, () => { + expect(findContent().exists()).toBe(showContent); + + if (showContent) { + expect(findContent().props()).toEqual({ + value: TEST_BLOB_LOADED.content, + fileGlobalId: TEST_BLOB_LOADED.id, + fileName: TEST_BLOB_LOADED.path, }); - }); + } }); }); }); diff --git a/spec/frontend/snippets/components/snippet_blob_view_spec.js b/spec/frontend/snippets/components/snippet_blob_view_spec.js index c8f1c8fc8a9..9c4b2734a3f 100644 --- a/spec/frontend/snippets/components/snippet_blob_view_spec.js +++ b/spec/frontend/snippets/components/snippet_blob_view_spec.js @@ -1,7 +1,14 @@ +import { nextTick } from 'vue'; import { mount } from '@vue/test-utils'; +import { + Blob as BlobMock, + SimpleViewerMock, + RichViewerMock, + RichBlobContentMock, + SimpleBlobContentMock, +} from 'jest/blob/components/mock_data'; import SnippetBlobView from '~/snippets/components/snippet_blob_view.vue'; import BlobHeader from '~/blob/components/blob_header.vue'; -import BlobEmbeddable from '~/blob/components/blob_embeddable.vue'; import BlobContent from '~/blob/components/blob_content.vue'; import { BLOB_RENDER_EVENT_LOAD, @@ -9,13 +16,7 @@ import { BLOB_RENDER_ERRORS, } from '~/blob/components/constants'; import { RichViewer, SimpleViewer } from '~/vue_shared/components/blob_viewers'; -import { - SNIPPET_VISIBILITY_PRIVATE, - SNIPPET_VISIBILITY_INTERNAL, - SNIPPET_VISIBILITY_PUBLIC, -} from '~/snippets/constants'; - -import { Blob as BlobMock, SimpleViewerMock, RichViewerMock } from 'jest/blob/components/mock_data'; +import { SNIPPET_VISIBILITY_PUBLIC } from '~/snippets/constants'; describe('Blob Embeddable', () => { let wrapper; @@ -72,18 +73,6 @@ describe('Blob Embeddable', () => { expect(wrapper.find(BlobContent).exists()).toBe(true); }); - it.each([SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PRIVATE, 'foo'])( - 'does not render blob-embeddable by default', - visibilityLevel => { - createComponent({ - snippetProps: { - visibilityLevel, - }, - }); - expect(wrapper.find(BlobEmbeddable).exists()).toBe(false); - }, - ); - it('sets simple viewer correctly', () => { createComponent(); expect(wrapper.find(SimpleViewer).exists()).toBe(true); @@ -128,6 +117,59 @@ describe('Blob Embeddable', () => { expect(wrapper.find(BlobHeader).props('hasRenderError')).toBe(true); }); + describe('bob content in multi-file scenario', () => { + const SimpleBlobContentMock2 = { + ...SimpleBlobContentMock, + plainData: 'Another Plain Foo', + }; + const RichBlobContentMock2 = { + ...SimpleBlobContentMock, + richData: 'Another Rich Foo', + }; + + it.each` + snippetBlobs | description | currentBlob | expectedContent + ${[SimpleBlobContentMock]} | ${'one existing textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} + ${[RichBlobContentMock]} | ${'one existing rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} + ${[SimpleBlobContentMock, RichBlobContentMock]} | ${'mixed blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + ${[SimpleBlobContentMock, SimpleBlobContentMock2]} | ${'textual blobs with current textual blob'} | ${SimpleBlobContentMock} | ${SimpleBlobContentMock.plainData} + ${[RichBlobContentMock, RichBlobContentMock2]} | ${'rich blobs with current rich blob'} | ${RichBlobContentMock} | ${RichBlobContentMock.richData} + `( + 'renders correct content for $description', + async ({ snippetBlobs, currentBlob, expectedContent }) => { + const apolloData = { + snippets: { + edges: [ + { + node: { + blobs: snippetBlobs, + }, + }, + ], + }, + }; + createComponent({ + blob: { + ...BlobMock, + path: currentBlob.path, + }, + }); + + // mimic apollo's update + wrapper.setData({ + blobContent: wrapper.vm.onContentUpdate(apolloData), + }); + + await nextTick(); + + const findContent = () => wrapper.find(BlobContent); + + expect(findContent().props('content')).toBe(expectedContent); + }, + ); + }); + describe('URLS with hash', () => { beforeEach(() => { window.location.hash = '#LC2'; diff --git a/spec/frontend/snippets/components/snippet_description_edit_spec.js b/spec/frontend/snippets/components/snippet_description_edit_spec.js index 816ab4e48de..ff75515e71a 100644 --- a/spec/frontend/snippets/components/snippet_description_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_description_edit_spec.js @@ -1,6 +1,6 @@ +import { shallowMount } from '@vue/test-utils'; import SnippetDescriptionEdit from '~/snippets/components/snippet_description_edit.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import { shallowMount } from '@vue/test-utils'; describe('Snippet Description Edit component', () => { let wrapper; diff --git a/spec/frontend/snippets/components/snippet_description_view_spec.js b/spec/frontend/snippets/components/snippet_description_view_spec.js index 46467ef311e..14f116f2aaf 100644 --- a/spec/frontend/snippets/components/snippet_description_view_spec.js +++ b/spec/frontend/snippets/components/snippet_description_view_spec.js @@ -1,5 +1,5 @@ -import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; import { shallowMount } from '@vue/test-utils'; +import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; describe('Snippet Description component', () => { let wrapper; diff --git a/spec/frontend/snippets/components/snippet_header_spec.js b/spec/frontend/snippets/components/snippet_header_spec.js index 0825da92118..da8cb2e6a8d 100644 --- a/spec/frontend/snippets/components/snippet_header_spec.js +++ b/spec/frontend/snippets/components/snippet_header_spec.js @@ -1,46 +1,19 @@ -import SnippetHeader from '~/snippets/components/snippet_header.vue'; -import DeleteSnippetMutation from '~/snippets/mutations/deleteSnippet.mutation.graphql'; import { ApolloMutation } from 'vue-apollo'; import { GlButton, GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; 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'; describe('Snippet header component', () => { let wrapper; - const snippet = { - id: 'gid://gitlab/PersonalSnippet/50', - title: 'The property of Thor', - visibilityLevel: 'private', - webUrl: 'http://personal.dev.null/42', - userPermissions: { - adminSnippet: true, - updateSnippet: true, - reportSnippet: false, - }, - project: null, - author: { - name: 'Thor Odinson', - }, - blobs: [Blob], - }; - const mutationVariables = { - mutation: DeleteSnippetMutation, - variables: { - id: snippet.id, - }, - }; - const errorMsg = 'Foo bar'; - const err = { message: errorMsg }; - - const resolveMutate = jest.fn(() => - Promise.resolve({ data: { destroySnippet: { errors: [] } } }), - ); - const rejectMutation = jest.fn(() => Promise.reject(err)); - - const mutationTypes = { - RESOLVE: resolveMutate, - REJECT: rejectMutation, - }; + let snippet; + let mutationTypes; + let mutationVariables; + + let errorMsg; + let err; function createComponent({ loading = false, @@ -63,7 +36,7 @@ describe('Snippet header component', () => { mutate: mutationRes, }; - wrapper = shallowMount(SnippetHeader, { + wrapper = mount(SnippetHeader, { mocks: { $apollo }, propsData: { snippet: { @@ -76,6 +49,41 @@ describe('Snippet header component', () => { }); } + beforeEach(() => { + snippet = { + id: 'gid://gitlab/PersonalSnippet/50', + title: 'The property of Thor', + visibilityLevel: 'private', + webUrl: 'http://personal.dev.null/42', + userPermissions: { + adminSnippet: true, + updateSnippet: true, + reportSnippet: false, + }, + project: null, + author: { + name: 'Thor Odinson', + }, + blobs: [Blob], + createdAt: new Date(Date.now() - 32 * 24 * 3600 * 1000).toISOString(), + }; + + mutationVariables = { + mutation: DeleteSnippetMutation, + variables: { + id: snippet.id, + }, + }; + + errorMsg = 'Foo bar'; + err = { message: errorMsg }; + + mutationTypes = { + RESOLVE: jest.fn(() => Promise.resolve({ data: { destroySnippet: { errors: [] } } })), + REJECT: jest.fn(() => Promise.reject(err)), + }; + }); + afterEach(() => { wrapper.destroy(); }); @@ -85,6 +93,23 @@ describe('Snippet header component', () => { expect(wrapper.find('.detail-page-header').exists()).toBe(true); }); + it('renders a message showing snippet creation date and author', () => { + createComponent(); + + const text = wrapper.find('[data-testid="authored-message"]').text(); + expect(text).toContain('Authored 1 month ago by'); + expect(text).toContain('Thor Odinson'); + }); + + it('renders a message showing only snippet creation date if author is null', () => { + snippet.author = null; + + createComponent(); + + const text = wrapper.find('[data-testid="authored-message"]').text(); + expect(text).toBe('Authored 1 month ago'); + }); + it('renders action buttons based on permissions', () => { createComponent({ permissions: { @@ -163,14 +188,15 @@ describe('Snippet header component', () => { expect(mutationTypes.RESOLVE).toHaveBeenCalledWith(mutationVariables); }); - it('sets error message if mutation fails', () => { + it('sets error message if mutation fails', async () => { createComponent({ mutationRes: mutationTypes.REJECT }); expect(Boolean(wrapper.vm.errorMessage)).toBe(false); wrapper.vm.deleteSnippet(); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.errorMessage).toEqual(errorMsg); - }); + + await waitForPromises(); + + expect(wrapper.vm.errorMessage).toEqual(errorMsg); }); describe('in case of successful mutation, closes modal and redirects to correct listing', () => { @@ -199,7 +225,7 @@ describe('Snippet header component', () => { }, }).then(() => { expect(wrapper.vm.closeDeleteModal).toHaveBeenCalled(); - expect(window.location.pathname).toBe(`${fullPath}/snippets`); + expect(window.location.pathname).toBe(`${fullPath}/-/snippets`); }); }); }); diff --git a/spec/frontend/snippets/components/snippet_title_spec.js b/spec/frontend/snippets/components/snippet_title_spec.js index 88261a75f6c..f201cfb19b7 100644 --- a/spec/frontend/snippets/components/snippet_title_spec.js +++ b/spec/frontend/snippets/components/snippet_title_spec.js @@ -1,7 +1,7 @@ -import SnippetTitle from '~/snippets/components/snippet_title.vue'; -import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; import { GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; +import SnippetTitle from '~/snippets/components/snippet_title.vue'; +import SnippetDescription from '~/snippets/components/snippet_description_view.vue'; describe('Snippet header component', () => { let wrapper; diff --git a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js index 0bdef71bc08..a8df13787a5 100644 --- a/spec/frontend/snippets/components/snippet_visibility_edit_spec.js +++ b/spec/frontend/snippets/components/snippet_visibility_edit_spec.js @@ -1,12 +1,12 @@ -import SnippetVisibilityEdit from '~/snippets/components/snippet_visibility_edit.vue'; 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 { SNIPPET_VISIBILITY, SNIPPET_VISIBILITY_PRIVATE, SNIPPET_VISIBILITY_INTERNAL, SNIPPET_VISIBILITY_PUBLIC, } from '~/snippets/constants'; -import { mount, shallowMount } from '@vue/test-utils'; describe('Snippet Visibility Edit component', () => { let wrapper; diff --git a/spec/frontend/snippets/test_utils.js b/spec/frontend/snippets/test_utils.js new file mode 100644 index 00000000000..86262723157 --- /dev/null +++ b/spec/frontend/snippets/test_utils.js @@ -0,0 +1,76 @@ +import { + SNIPPET_BLOB_ACTION_CREATE, + SNIPPET_BLOB_ACTION_UPDATE, + SNIPPET_BLOB_ACTION_MOVE, + SNIPPET_BLOB_ACTION_DELETE, +} from '~/snippets/constants'; + +const CONTENT_1 = 'Lorem ipsum dolar\nSit amit\n\nGoodbye!\n'; +const CONTENT_2 = 'Lorem ipsum dolar sit amit.\n\nGoodbye!\n'; + +export const testEntries = { + created: { + id: 'blob_1', + diff: { + action: SNIPPET_BLOB_ACTION_CREATE, + filePath: '/new/file', + previousPath: '/new/file', + content: CONTENT_1, + }, + }, + deleted: { + id: 'blob_2', + diff: { + action: SNIPPET_BLOB_ACTION_DELETE, + filePath: '/src/delete/me', + previousPath: '/src/delete/me', + content: CONTENT_1, + }, + }, + updated: { + id: 'blob_3', + origContent: CONTENT_1, + diff: { + action: SNIPPET_BLOB_ACTION_UPDATE, + filePath: '/lorem.md', + previousPath: '/lorem.md', + content: CONTENT_2, + }, + }, + renamed: { + id: 'blob_4', + diff: { + action: SNIPPET_BLOB_ACTION_MOVE, + filePath: '/dolar.md', + previousPath: '/ipsum.md', + content: CONTENT_1, + }, + }, + renamedAndUpdated: { + id: 'blob_5', + origContent: CONTENT_1, + diff: { + action: SNIPPET_BLOB_ACTION_MOVE, + filePath: '/sit.md', + previousPath: '/sit/amit.md', + content: CONTENT_2, + }, + }, +}; + +export const createBlobFromTestEntry = ({ diff, origContent }, isOrig = false) => ({ + content: isOrig && origContent ? origContent : diff.content, + path: isOrig ? diff.previousPath : diff.filePath, +}); + +export const createBlobsFromTestEntries = (entries, isOrig = false) => + entries.reduce( + (acc, entry) => + Object.assign(acc, { + [entry.id]: { + id: entry.id, + ...createBlobFromTestEntry(entry, isOrig), + }, + }), + {}, + ); diff --git a/spec/frontend/snippets/utils/blob_spec.js b/spec/frontend/snippets/utils/blob_spec.js new file mode 100644 index 00000000000..c20cf2e6102 --- /dev/null +++ b/spec/frontend/snippets/utils/blob_spec.js @@ -0,0 +1,63 @@ +import { cloneDeep } from 'lodash'; +import { decorateBlob, createBlob, diffAll } from '~/snippets/utils/blob'; +import { testEntries, createBlobsFromTestEntries } from '../test_utils'; + +jest.mock('lodash/uniqueId', () => arg => `${arg}fakeUniqueId`); + +const TEST_RAW_BLOB = { + rawPath: '/test/blob/7/raw', +}; + +describe('~/snippets/utils/blob', () => { + describe('decorateBlob', () => { + it('should decorate the given object with local blob properties', () => { + const orig = cloneDeep(TEST_RAW_BLOB); + + expect(decorateBlob(orig)).toEqual({ + ...TEST_RAW_BLOB, + id: 'blob_local_fakeUniqueId', + isLoaded: false, + content: '', + }); + }); + }); + + describe('createBlob', () => { + it('should create an empty local blob', () => { + expect(createBlob()).toEqual({ + id: 'blob_local_fakeUniqueId', + isLoaded: true, + content: '', + path: '', + }); + }); + }); + + describe('diffAll', () => { + it('should create diff from original files', () => { + const origBlobs = createBlobsFromTestEntries( + [ + testEntries.deleted, + testEntries.updated, + testEntries.renamed, + testEntries.renamedAndUpdated, + ], + true, + ); + const blobs = createBlobsFromTestEntries([ + testEntries.created, + testEntries.updated, + testEntries.renamed, + testEntries.renamedAndUpdated, + ]); + + expect(diffAll(blobs, origBlobs)).toEqual([ + testEntries.deleted.diff, + testEntries.created.diff, + testEntries.updated.diff, + testEntries.renamed.diff, + testEntries.renamedAndUpdated.diff, + ]); + }); + }); +}); diff --git a/spec/frontend/snippets_spec.js b/spec/frontend/snippets_spec.js index 5b391606371..6c39ff0da27 100644 --- a/spec/frontend/snippets_spec.js +++ b/spec/frontend/snippets_spec.js @@ -7,7 +7,7 @@ describe('Snippets', () => { let shareBtn; let scriptTag; - const snippetUrl = 'http://test.host/snippets/1'; + const snippetUrl = 'http://test.host/-/snippets/1'; beforeEach(() => { loadHTMLFixture('snippets/show.html'); diff --git a/spec/frontend/static_site_editor/components/app_spec.js b/spec/frontend/static_site_editor/components/app_spec.js new file mode 100644 index 00000000000..bbdffeae68f --- /dev/null +++ b/spec/frontend/static_site_editor/components/app_spec.js @@ -0,0 +1,34 @@ +import { shallowMount } from '@vue/test-utils'; +import App from '~/static_site_editor/components/app.vue'; + +describe('static_site_editor/components/app', () => { + const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg'; + const RouterView = { + template: '<div></div>', + }; + let wrapper; + + const buildWrapper = () => { + wrapper = shallowMount(App, { + stubs: { + RouterView, + }, + propsData: { + mergeRequestsIllustrationPath, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('passes merge request illustration path to the router view component', () => { + buildWrapper(); + + expect(wrapper.find(RouterView).attributes()).toMatchObject({ + 'merge-requests-illustration-path': mergeRequestsIllustrationPath, + }); + }); +}); 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 11c5abf1b08..f4be911171e 100644 --- a/spec/frontend/static_site_editor/components/edit_area_spec.js +++ b/spec/frontend/static_site_editor/components/edit_area_spec.js @@ -15,8 +15,11 @@ import { returnUrl, } from '../mock_data'; +jest.mock('~/static_site_editor/services/formatter', () => jest.fn(str => `${str} format-pass`)); + describe('~/static_site_editor/components/edit_area.vue', () => { let wrapper; + const formattedBody = `${body} format-pass`; const savingChanges = true; const newBody = `new ${body}`; @@ -50,9 +53,9 @@ describe('~/static_site_editor/components/edit_area.vue', () => { expect(findEditHeader().props('title')).toBe(title); }); - it('renders rich content editor', () => { + it('renders rich content editor with a format pass', () => { expect(findRichContentEditor().exists()).toBe(true); - expect(findRichContentEditor().props('content')).toBe(body); + expect(findRichContentEditor().props('content')).toBe(formattedBody); }); it('renders publish toolbar', () => { @@ -94,7 +97,7 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }); it('sets publish toolbar as not saveable when content changes are rollback', () => { - findRichContentEditor().vm.$emit('input', body); + findRichContentEditor().vm.$emit('input', formattedBody); return wrapper.vm.$nextTick().then(() => { expect(findPublishToolbar().props('saveable')).toBe(false); @@ -103,31 +106,53 @@ describe('~/static_site_editor/components/edit_area.vue', () => { }); describe('when the mode changes', () => { + let resetInitialValue; + const setInitialMode = mode => { wrapper.setData({ editorMode: mode }); }; + const buildResetInitialValue = () => { + resetInitialValue = jest.fn(); + findRichContentEditor().setMethods({ resetInitialValue }); + }; + afterEach(() => { setInitialMode(EDITOR_TYPES.wysiwyg); + resetInitialValue = null; }); it.each` initialMode | targetMode | resetValue - ${EDITOR_TYPES.wysiwyg} | ${EDITOR_TYPES.markdown} | ${content} - ${EDITOR_TYPES.markdown} | ${EDITOR_TYPES.wysiwyg} | ${body} + ${EDITOR_TYPES.wysiwyg} | ${EDITOR_TYPES.markdown} | ${`${content} format-pass format-pass`} + ${EDITOR_TYPES.markdown} | ${EDITOR_TYPES.wysiwyg} | ${`${body} format-pass format-pass`} `( 'sets editorMode from $initialMode to $targetMode', ({ initialMode, targetMode, resetValue }) => { setInitialMode(initialMode); + buildResetInitialValue(); - const resetInitialValue = jest.fn(); - - findRichContentEditor().setMethods({ resetInitialValue }); findRichContentEditor().vm.$emit('modeChange', targetMode); expect(resetInitialValue).toHaveBeenCalledWith(resetValue); expect(wrapper.vm.editorMode).toBe(targetMode); }, ); + + it('should format the content', () => { + buildResetInitialValue(); + + findRichContentEditor().vm.$emit('modeChange', EDITOR_TYPES.markdown); + + expect(resetInitialValue).toHaveBeenCalledWith(`${content} format-pass format-pass`); + }); + }); + + 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`); + }); }); }); diff --git a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js b/spec/frontend/static_site_editor/components/saved_changes_message_spec.js deleted file mode 100644 index a63c3a83395..00000000000 --- a/spec/frontend/static_site_editor/components/saved_changes_message_spec.js +++ /dev/null @@ -1,56 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; - -import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue'; - -import { returnUrl, savedContentMeta } from '../mock_data'; - -describe('~/static_site_editor/components/saved_changes_message.vue', () => { - let wrapper; - const { branch, commit, mergeRequest } = savedContentMeta; - const props = { - branch, - commit, - mergeRequest, - returnUrl, - }; - const findReturnToSiteButton = () => wrapper.find({ ref: 'returnToSiteButton' }); - const findMergeRequestButton = () => wrapper.find({ ref: 'mergeRequestButton' }); - const findBranchLink = () => wrapper.find({ ref: 'branchLink' }); - const findCommitLink = () => wrapper.find({ ref: 'commitLink' }); - const findMergeRequestLink = () => wrapper.find({ ref: 'mergeRequestLink' }); - - beforeEach(() => { - wrapper = shallowMount(SavedChangesMessage, { - propsData: props, - }); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it.each` - text | findEl | url - ${'Return to site'} | ${findReturnToSiteButton} | ${props.returnUrl} - ${'View merge request'} | ${findMergeRequestButton} | ${props.mergeRequest.url} - `('renders "$text" button link', ({ text, findEl, url }) => { - const btn = findEl(); - - expect(btn.exists()).toBe(true); - expect(btn.text()).toBe(text); - expect(btn.attributes('href')).toBe(url); - }); - - it.each` - desc | findEl | prop - ${'branch'} | ${findBranchLink} | ${props.branch} - ${'commit'} | ${findCommitLink} | ${props.commit} - ${'merge request'} | ${findMergeRequestLink} | ${props.mergeRequest} - `('renders $desc link', ({ findEl, prop }) => { - const el = findEl(); - - expect(el.exists()).toBe(true); - expect(el.text()).toBe(prop.label); - expect(el.attributes('href')).toBe(prop.url); - }); -}); diff --git a/spec/frontend/static_site_editor/pages/home_spec.js b/spec/frontend/static_site_editor/pages/home_spec.js index d3ee70785d1..c5473596df8 100644 --- a/spec/frontend/static_site_editor/pages/home_spec.js +++ b/spec/frontend/static_site_editor/pages/home_spec.js @@ -1,5 +1,6 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import Home from '~/static_site_editor/pages/home.vue'; import SkeletonLoader from '~/static_site_editor/components/skeleton_loader.vue'; import EditArea from '~/static_site_editor/components/edit_area.vue'; @@ -7,7 +8,6 @@ import InvalidContentMessage from '~/static_site_editor/components/invalid_conte import SubmitChangesError from '~/static_site_editor/components/submit_changes_error.vue'; import submitContentChangesMutation from '~/static_site_editor/graphql/mutations/submit_content_changes.mutation.graphql'; import { SUCCESS_ROUTE } from '~/static_site_editor/router/constants'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { TRACKING_ACTION_INITIALIZE_EDITOR } from '~/static_site_editor/constants'; import { diff --git a/spec/frontend/static_site_editor/pages/success_spec.js b/spec/frontend/static_site_editor/pages/success_spec.js index d62b67bfa83..3e19e2413e7 100644 --- a/spec/frontend/static_site_editor/pages/success_spec.js +++ b/spec/frontend/static_site_editor/pages/success_spec.js @@ -1,17 +1,12 @@ -import Vuex from 'vuex'; -import { shallowMount, createLocalVue } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; +import { GlEmptyState, GlButton } from '@gitlab/ui'; import Success from '~/static_site_editor/pages/success.vue'; -import SavedChangesMessage from '~/static_site_editor/components/saved_changes_message.vue'; -import { savedContentMeta, returnUrl } from '../mock_data'; +import { savedContentMeta, returnUrl, sourcePath } from '../mock_data'; import { HOME_ROUTE } from '~/static_site_editor/router/constants'; -const localVue = createLocalVue(); - -localVue.use(Vuex); - describe('static_site_editor/pages/success', () => { + const mergeRequestsIllustrationPath = 'illustrations/merge_requests.svg'; let wrapper; - let store; let router; const buildRouter = () => { @@ -22,16 +17,22 @@ describe('static_site_editor/pages/success', () => { const buildWrapper = (data = {}) => { wrapper = shallowMount(Success, { - localVue, - store, mocks: { $router: router, }, + stubs: { + GlEmptyState, + GlButton, + }, + propsData: { + mergeRequestsIllustrationPath, + }, data() { return { savedContentMeta, appData: { returnUrl, + sourcePath, }, ...data, }; @@ -39,7 +40,8 @@ describe('static_site_editor/pages/success', () => { }); }; - const findSavedChangesMessage = () => wrapper.find(SavedChangesMessage); + const findEmptyState = () => wrapper.find(GlEmptyState); + const findReturnUrlButton = () => wrapper.find(GlButton); beforeEach(() => { buildRouter(); @@ -50,29 +52,50 @@ describe('static_site_editor/pages/success', () => { wrapper = null; }); - it('renders saved changes message', () => { + it('renders empty state with a link to the created merge request', () => { + buildWrapper(); + + expect(findEmptyState().exists()).toBe(true); + expect(findEmptyState().props()).toMatchObject({ + primaryButtonText: 'View merge request', + primaryButtonLink: savedContentMeta.mergeRequest.url, + title: 'Your merge request has been created', + svgPath: mergeRequestsIllustrationPath, + }); + }); + + it('displays merge request instructions in the empty state', () => { buildWrapper(); - expect(findSavedChangesMessage().exists()).toBe(true); + expect(findEmptyState().text()).toContain( + 'To see your changes live you will need to do the following things:', + ); + expect(findEmptyState().text()).toContain('1. Add a clear title to describe the change.'); + expect(findEmptyState().text()).toContain( + '2. Add a description to explain why the change is being made.', + ); + expect(findEmptyState().text()).toContain( + '3. Assign a person to review and accept the merge request.', + ); }); - it('passes returnUrl to the saved changes message', () => { + it('displays return to site button', () => { buildWrapper(); - expect(findSavedChangesMessage().props('returnUrl')).toBe(returnUrl); + expect(findReturnUrlButton().text()).toBe('Return to site'); + expect(findReturnUrlButton().attributes().href).toBe(returnUrl); }); - it('passes saved content metadata to the saved changes message', () => { + it('displays source path', () => { buildWrapper(); - expect(findSavedChangesMessage().props('branch')).toBe(savedContentMeta.branch); - expect(findSavedChangesMessage().props('commit')).toBe(savedContentMeta.commit); - expect(findSavedChangesMessage().props('mergeRequest')).toBe(savedContentMeta.mergeRequest); + expect(wrapper.text()).toContain(`Update ${sourcePath} file`); }); it('redirects to the HOME route when content has not been submitted', () => { buildWrapper({ savedContentMeta: null }); expect(router.push).toHaveBeenCalledWith(HOME_ROUTE); + expect(wrapper.html()).toBe(''); }); }); diff --git a/spec/frontend/static_site_editor/services/formatter_spec.js b/spec/frontend/static_site_editor/services/formatter_spec.js new file mode 100644 index 00000000000..b7600798db9 --- /dev/null +++ b/spec/frontend/static_site_editor/services/formatter_spec.js @@ -0,0 +1,26 @@ +import formatter from '~/static_site_editor/services/formatter'; + +describe('formatter', () => { + const source = `Some text +<br> + +And some more text + + +<br> + + +And even more text`; + const sourceWithoutBrTags = `Some text + +And some more text + + + + +And even more text`; + + it('removes extraneous <br> tags', () => { + expect(formatter(source)).toMatch(sourceWithoutBrTags); + }); +}); 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 a9169eb3e16..645ccedf7e7 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 @@ -1,6 +1,6 @@ +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import Api from '~/api'; import { convertObjectPropsToSnakeCase } from '~/lib/utils/common_utils'; -import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { DEFAULT_TARGET_BRANCH, diff --git a/spec/frontend/static_site_editor/services/templater_spec.js b/spec/frontend/static_site_editor/services/templater_spec.js new file mode 100644 index 00000000000..1e7ae872b7e --- /dev/null +++ b/spec/frontend/static_site_editor/services/templater_spec.js @@ -0,0 +1,104 @@ +/* eslint-disable no-useless-escape */ +import templater from '~/static_site_editor/services/templater'; + +describe('templater', () => { + const source = `Below this line is a simple ERB (single-line erb block) example. + +<% some erb code %> + +Below this line is a complex ERB (multi-line erb block) example. + +<% if apptype.maturity && (apptype.maturity != "planned") %> + <% maturity = "This application type is at the \"#{apptype.maturity}\" level of maturity." %> +<% end %> + +Below this line is a non-erb (single-line HTML) markup example that also has erb. + +<a href="<%= compensation_roadmap.role_path %>"><%= compensation_roadmap.role_path %></a> + +Below this line is a non-erb (multi-line HTML block) markup example that also has erb. + +<ul> +<% compensation_roadmap.recommendation.recommendations.each do |recommendation| %> + <li><%= recommendation %></li> +<% end %> +</ul> + +Below this line is a block of HTML. + +<div> + <h1>Heading</h1> + <p>Some paragraph...</p> +</div> + +Below this line is a codeblock of the same HTML that should be ignored and preserved. + +\`\`\` html +<div> + <h1>Heading</h1> + <p>Some paragraph...</p> +</div> +\`\`\` +`; + const sourceTemplated = `Below this line is a simple ERB (single-line erb block) example. + +\`\`\` sse +<% some erb code %> +\`\`\` + +Below this line is a complex ERB (multi-line erb block) example. + +\`\`\` sse +<% if apptype.maturity && (apptype.maturity != "planned") %> + <% maturity = "This application type is at the \"#{apptype.maturity}\" level of maturity." %> +<% end %> +\`\`\` + +Below this line is a non-erb (single-line HTML) markup example that also has erb. + +\`\`\` sse +<a href="<%= compensation_roadmap.role_path %>"><%= compensation_roadmap.role_path %></a> +\`\`\` + +Below this line is a non-erb (multi-line HTML block) markup example that also has erb. + +\`\`\` sse +<ul> +<% compensation_roadmap.recommendation.recommendations.each do |recommendation| %> + <li><%= recommendation %></li> +<% end %> +</ul> +\`\`\` + +Below this line is a block of HTML. + +\`\`\` sse +<div> + <h1>Heading</h1> + <p>Some paragraph...</p> +</div> +\`\`\` + +Below this line is a codeblock of the same HTML that should be ignored and preserved. + +\`\`\` html +<div> + <h1>Heading</h1> + <p>Some paragraph...</p> +</div> +\`\`\` +`; + + it.each` + fn | initial | target + ${'wrap'} | ${source} | ${sourceTemplated} + ${'wrap'} | ${sourceTemplated} | ${sourceTemplated} + ${'unwrap'} | ${sourceTemplated} | ${source} + ${'unwrap'} | ${source} | ${source} + `( + 'wraps $initial in a templated sse codeblocks if $fn is wrap, unwraps otherwise', + ({ fn, initial, target }) => { + expect(templater[fn](initial)).toMatch(target); + }, + ); +}); diff --git a/spec/frontend/test_setup.js b/spec/frontend/test_setup.js index 49eae715a45..544c19da57b 100644 --- a/spec/frontend/test_setup.js +++ b/spec/frontend/test_setup.js @@ -24,7 +24,7 @@ afterEach(() => }), ); -initializeTestTimeout(process.env.CI ? 5000 : 500); +initializeTestTimeout(process.env.CI ? 6000 : 500); Vue.config.devtools = false; Vue.config.productionTip = false; diff --git a/spec/frontend/vue_alerts_spec.js b/spec/frontend/vue_alerts_spec.js index b2ee6f895a8..b52737e6106 100644 --- a/spec/frontend/vue_alerts_spec.js +++ b/spec/frontend/vue_alerts_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; -import initVueAlerts from '~/vue_alerts'; import { setHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; +import initVueAlerts from '~/vue_alerts'; describe('VueAlerts', () => { const alerts = [ diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js index e39f66d3f30..65ca3639dcc 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_spec.js @@ -3,7 +3,7 @@ import { GlButton } from '@gitlab/ui'; import Approvals from '~/vue_merge_request_widget/components/approvals/approvals.vue'; import ApprovalsSummary from '~/vue_merge_request_widget/components/approvals/approvals_summary.vue'; import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { FETCH_LOADING, FETCH_ERROR, diff --git a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js index 77fad7f51ab..d9a5230f55f 100644 --- a/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js +++ b/spec/frontend/vue_mr_widget/components/approvals/approvals_summary_optional_spec.js @@ -1,9 +1,5 @@ import { shallowMount } from '@vue/test-utils'; import { GlLink } from '@gitlab/ui'; -import { - OPTIONAL, - OPTIONAL_CAN_APPROVE, -} from '~/vue_merge_request_widget/components/approvals/messages'; import ApprovalsSummaryOptional from '~/vue_merge_request_widget/components/approvals/approvals_summary_optional.vue'; const TEST_HELP_PATH = 'help/path'; @@ -29,10 +25,6 @@ describe('MRWidget approvals summary optional', () => { createComponent({ canApprove: true, helpPath: TEST_HELP_PATH }); }); - it('shows optional can approve message', () => { - expect(wrapper.text()).toEqual(OPTIONAL_CAN_APPROVE); - }); - it('shows help link', () => { const link = findHelpLink(); @@ -46,10 +38,6 @@ describe('MRWidget approvals summary optional', () => { createComponent({ canApprove: false, helpPath: TEST_HELP_PATH }); }); - it('shows optional message', () => { - expect(wrapper.text()).toEqual(OPTIONAL); - }); - it('does not show help link', () => { expect(findHelpLink().exists()).toBe(false); }); diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js index 5f3a8654990..d67f1adadf2 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_pipeline_container_spec.js @@ -1,9 +1,9 @@ import { mount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import MrWidgetPipelineContainer from '~/vue_merge_request_widget/components/mr_widget_pipeline_container.vue'; import MrWidgetPipeline from '~/vue_merge_request_widget/components/mr_widget_pipeline.vue'; import ArtifactsApp from '~/vue_merge_request_widget/components/artifacts_list_app.vue'; import { mockStore } from '../mock_data'; -import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; describe('MrWidgetPipelineContainer', () => { diff --git a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js index d6c996f7501..8fcc982ac99 100644 --- a/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js +++ b/spec/frontend/vue_mr_widget/components/mr_widget_suggest_pipeline_spec.js @@ -1,119 +1,156 @@ -import { mount } from '@vue/test-utils'; +import { mount, shallowMount } from '@vue/test-utils'; import { GlLink, GlSprintf } from '@gitlab/ui'; +import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; +import MockAdapter from 'axios-mock-adapter'; import suggestPipelineComponent from '~/vue_merge_request_widget/components/mr_widget_suggest_pipeline.vue'; import MrWidgetIcon from '~/vue_merge_request_widget/components/mr_widget_icon.vue'; -import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; -import { popoverProps, iconName } from './pipeline_tour_mock_data'; +import dismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; +import { suggestProps, iconName } from './pipeline_tour_mock_data'; +import axios from '~/lib/utils/axios_utils'; +import { + SP_TRACK_LABEL, + SP_LINK_TRACK_EVENT, + SP_SHOW_TRACK_EVENT, + SP_LINK_TRACK_VALUE, + SP_SHOW_TRACK_VALUE, + SP_HELP_URL, +} from '~/vue_merge_request_widget/constants'; describe('MRWidgetSuggestPipeline', () => { - let wrapper; - let trackingSpy; - - const mockTrackingOnWrapper = () => { - unmockTracking(); - trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); - }; - - beforeEach(() => { - document.body.dataset.page = 'projects:merge_requests:show'; - trackingSpy = mockTracking('_category_', undefined, jest.spyOn); - - wrapper = mount(suggestPipelineComponent, { - propsData: popoverProps, - stubs: { - GlSprintf, - }, + describe('template', () => { + let wrapper; + + afterEach(() => { + wrapper.destroy(); }); - }); - afterEach(() => { - wrapper.destroy(); - unmockTracking(); - }); + describe('core functionality', () => { + const findOkBtn = () => wrapper.find('[data-testid="ok"]'); + let trackingSpy; + let mockAxios; + + const mockTrackingOnWrapper = () => { + unmockTracking(); + trackingSpy = mockTracking('_category_', wrapper.element, jest.spyOn); + }; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + document.body.dataset.page = 'projects:merge_requests:show'; + trackingSpy = mockTracking('_category_', undefined, jest.spyOn); + + wrapper = mount(suggestPipelineComponent, { + propsData: suggestProps, + stubs: { + GlSprintf, + }, + }); + }); - describe('template', () => { - const findOkBtn = () => wrapper.find('[data-testid="ok"]'); + afterEach(() => { + unmockTracking(); + mockAxios.restore(); + }); - it('renders add pipeline file link', () => { - const link = wrapper.find(GlLink); + it('renders add pipeline file link', () => { + const link = wrapper.find(GlLink); - expect(link.exists()).toBe(true); - expect(link.attributes().href).toBe(popoverProps.pipelinePath); - }); + expect(link.exists()).toBe(true); + expect(link.attributes().href).toBe(suggestProps.pipelinePath); + }); - it('renders the expected text', () => { - const messageText = /\s*No pipeline\s*Add the .gitlab-ci.yml file\s*to create one./; + it('renders the expected text', () => { + const messageText = /\s*No pipeline\s*Add the .gitlab-ci.yml file\s*to create one./; - expect(wrapper.text()).toMatch(messageText); - }); + expect(wrapper.text()).toMatch(messageText); + }); - it('renders widget icon', () => { - const icon = wrapper.find(MrWidgetIcon); + it('renders widget icon', () => { + const icon = wrapper.find(MrWidgetIcon); - expect(icon.exists()).toBe(true); - expect(icon.props()).toEqual( - expect.objectContaining({ - name: iconName, - }), - ); - }); + expect(icon.exists()).toBe(true); + expect(icon.props()).toEqual( + expect.objectContaining({ + name: iconName, + }), + ); + }); - it('renders the show me how button', () => { - const button = findOkBtn(); + it('renders the show me how button', () => { + const button = findOkBtn(); - expect(button.exists()).toBe(true); - expect(button.classes('btn-info')).toEqual(true); - expect(button.attributes('href')).toBe(popoverProps.pipelinePath); - }); + expect(button.exists()).toBe(true); + expect(button.classes('btn-info')).toEqual(true); + expect(button.attributes('href')).toBe(suggestProps.pipelinePath); + }); - it('renders the help link', () => { - const link = wrapper.find('[data-testid="help"]'); + it('renders the help link', () => { + const link = wrapper.find('[data-testid="help"]'); - expect(link.exists()).toBe(true); - expect(link.attributes('href')).toBe(wrapper.vm.$options.helpURL); - }); + expect(link.exists()).toBe(true); + expect(link.attributes('href')).toBe(SP_HELP_URL); + }); - it('renders the empty pipelines image', () => { - const image = wrapper.find('[data-testid="pipeline-image"]'); + it('renders the empty pipelines image', () => { + const image = wrapper.find('[data-testid="pipeline-image"]'); - expect(image.exists()).toBe(true); - expect(image.attributes().src).toBe(popoverProps.pipelineSvgPath); - }); + expect(image.exists()).toBe(true); + expect(image.attributes().src).toBe(suggestProps.pipelineSvgPath); + }); - describe('tracking', () => { - it('send event for basic view of the suggest pipeline widget', () => { - const expectedCategory = undefined; - const expectedAction = undefined; + describe('tracking', () => { + it('send event for basic view of the suggest pipeline widget', () => { + const expectedCategory = undefined; + const expectedAction = undefined; - expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, { - label: wrapper.vm.$options.trackLabel, - property: popoverProps.humanAccess, + expect(trackingSpy).toHaveBeenCalledWith(expectedCategory, expectedAction, { + label: SP_TRACK_LABEL, + property: suggestProps.humanAccess, + }); }); - }); - it('send an event when add pipeline link is clicked', () => { - mockTrackingOnWrapper(); - const link = wrapper.find('[data-testid="add-pipeline-link"]'); - triggerEvent(link.element); + it('send an event when add pipeline link is clicked', () => { + mockTrackingOnWrapper(); + const link = wrapper.find('[data-testid="add-pipeline-link"]'); + triggerEvent(link.element); - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_link', { - label: wrapper.vm.$options.trackLabel, - property: popoverProps.humanAccess, - value: '30', + expect(trackingSpy).toHaveBeenCalledWith('_category_', SP_LINK_TRACK_EVENT, { + label: SP_TRACK_LABEL, + property: suggestProps.humanAccess, + value: SP_LINK_TRACK_VALUE.toString(), + }); }); - }); - it('send an event when ok button is clicked', () => { - mockTrackingOnWrapper(); - const okBtn = findOkBtn(); - triggerEvent(okBtn.element); + it('send an event when ok button is clicked', () => { + mockTrackingOnWrapper(); + const okBtn = findOkBtn(); + triggerEvent(okBtn.element); - expect(trackingSpy).toHaveBeenCalledWith('_category_', 'click_button', { - label: wrapper.vm.$options.trackLabel, - property: popoverProps.humanAccess, - value: '10', + expect(trackingSpy).toHaveBeenCalledWith('_category_', SP_SHOW_TRACK_EVENT, { + label: SP_TRACK_LABEL, + property: suggestProps.humanAccess, + value: SP_SHOW_TRACK_VALUE.toString(), + }); }); }); }); + + describe('dismissible', () => { + const findDismissContainer = () => wrapper.find(dismissibleContainer); + + beforeEach(() => { + wrapper = shallowMount(suggestPipelineComponent, { propsData: suggestProps }); + }); + + it('renders the dismissal container', () => { + expect(findDismissContainer().exists()).toBe(true); + }); + + it('emits dismiss upon dismissal button click', () => { + findDismissContainer().vm.$emit('dismiss'); + + expect(wrapper.emitted().dismiss).toBeTruthy(); + }); + }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js b/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js index c749c434079..eef087d62b8 100644 --- a/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js +++ b/spec/frontend/vue_mr_widget/components/pipeline_tour_mock_data.js @@ -1,7 +1,9 @@ -export const popoverProps = { +export const suggestProps = { pipelinePath: '/foo/bar/add/pipeline/path', pipelineSvgPath: 'assets/illustrations/something.svg', humanAccess: 'maintainer', + userCalloutsPath: 'some/callout/path', + userCalloutFeatureId: 'suggest_pipeline', }; export const iconName = 'status_notfound'; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js index 56832f82b05..5c7e6a87c16 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_commit_message_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdownItem } from '@gitlab/ui'; import CommitMessageDropdown from '~/vue_merge_request_widget/components/states/commit_message_dropdown.vue'; const commits = [ @@ -39,7 +39,7 @@ describe('Commits message dropdown component', () => { wrapper.destroy(); }); - const findDropdownElements = () => wrapper.findAll(GlDropdownItem); + const findDropdownElements = () => wrapper.findAll(GlDeprecatedDropdownItem); const findFirstDropdownElement = () => findDropdownElements().at(0); it('should have 3 elements in dropdown list', () => { 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 d3482b457ad..c3a16a776a7 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 @@ -1,8 +1,8 @@ import $ from 'jquery'; import { createLocalVue, shallowMount } from '@vue/test-utils'; import { removeBreakLine } from 'helpers/text_helper'; -import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; import { TEST_HOST } from 'helpers/test_constants'; +import ConflictsComponent from '~/vue_merge_request_widget/components/states/mr_widget_conflicts.vue'; describe('MRWidgetConflicts', () => { let vm; diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js index 1542b0939aa..4c213899dbd 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_squash_before_merge_spec.js @@ -79,7 +79,7 @@ describe('Squash before merge component', () => { }); it(expectation, () => { - expect(findLabel().classes('gl-text-gray-600')).toBe(isDisabled); + expect(findLabel().classes('gl-text-gray-400')).toBe(isDisabled); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js index 33e52f4fd36..a5531577a8c 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_unresolved_discussions_spec.js @@ -1,46 +1,68 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; +import { mount } from '@vue/test-utils'; import { TEST_HOST } from 'helpers/test_constants'; +import UnresolvedDiscussions from '~/vue_merge_request_widget/components/states/unresolved_discussions.vue'; +import notesEventHub from '~/notes/event_hub'; + +function createComponent({ path = '' } = {}) { + return mount(UnresolvedDiscussions, { + propsData: { + mr: { + createIssueToResolveDiscussionsPath: path, + }, + }, + }); +} describe('UnresolvedDiscussions', () => { - const Component = Vue.extend(UnresolvedDiscussions); - let vm; + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); afterEach(() => { - vm.$destroy(); + wrapper.destroy(); + }); + + it('triggers the correct notes event when the jump to first unresolved discussion button is clicked', () => { + jest.spyOn(notesEventHub, '$emit'); + + wrapper.find('[data-testid="jump-to-first"]').trigger('click'); + + expect(notesEventHub.$emit).toHaveBeenCalledWith('jumpToFirstUnresolvedDiscussion'); }); describe('with threads path', () => { beforeEach(() => { - vm = mountComponent(Component, { - mr: { - createIssueToResolveDiscussionsPath: TEST_HOST, - }, - }); + wrapper = createComponent({ path: TEST_HOST }); + }); + + afterEach(() => { + wrapper.destroy(); }); it('should have correct elements', () => { - expect(vm.$el.innerText).toContain( - 'There are unresolved threads. Please resolve these threads', + expect(wrapper.element.innerText).toContain( + `Before this can be merged, one or more threads must be resolved.`, ); - expect(vm.$el.innerText).toContain('Create an issue to resolve them later'); - expect(vm.$el.querySelector('.js-create-issue').getAttribute('href')).toEqual(TEST_HOST); + expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); + expect(wrapper.element.innerText).toContain('Resolve all threads in new issue'); + expect(wrapper.element.querySelector('.js-create-issue').getAttribute('href')).toEqual( + TEST_HOST, + ); }); }); describe('without threads path', () => { - beforeEach(() => { - vm = mountComponent(Component, { mr: {} }); - }); - it('should not show create issue link if user cannot create issue', () => { - expect(vm.$el.innerText).toContain( - 'There are unresolved threads. Please resolve these threads', + expect(wrapper.element.innerText).toContain( + `Before this can be merged, one or more threads must be resolved.`, ); - expect(vm.$el.querySelector('.js-create-issue')).toEqual(null); + expect(wrapper.element.innerText).toContain('Jump to first unresolved thread'); + expect(wrapper.element.innerText).not.toContain('Resolve all threads in new issue'); + expect(wrapper.element.querySelector('.js-create-issue')).toEqual(null); }); }); }); diff --git a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js index 6fa555b4fc4..6ccf1e1f56b 100644 --- a/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js +++ b/spec/frontend/vue_mr_widget/components/states/mr_widget_wip_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import WorkInProgress from '~/vue_merge_request_widget/components/states/work_in_progress.vue'; import eventHub from '~/vue_merge_request_widget/event_hub'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; jest.mock('~/flash'); @@ -84,11 +84,11 @@ describe('Wip', () => { it('should have correct elements', () => { expect(el.classList.contains('mr-widget-body')).toBeTruthy(); - expect(el.innerText).toContain('This is a Work in Progress'); + expect(el.innerText).toContain('This merge request is still a work in progress.'); expect(el.querySelector('button').getAttribute('disabled')).toBeTruthy(); expect(el.querySelector('button').innerText).toContain('Merge'); expect(el.querySelector('.js-remove-wip').innerText.replace(/\s\s+/g, ' ')).toContain( - 'Resolve WIP status', + 'Mark as ready', ); }); 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 be43f10c03e..ffcf9b1477a 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,8 +1,8 @@ import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; -import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; import { shallowMount } from '@vue/test-utils'; -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; +import { invalidPlanWithName, plans, validPlanWithName } from './mock_data'; +import axios from '~/lib/utils/axios_utils'; import MrWidgetExpanableSection from '~/vue_merge_request_widget/components/mr_widget_expandable_section.vue'; import MrWidgetTerraformContainer from '~/vue_merge_request_widget/components/terraform/mr_widget_terraform_container.vue'; import Poll from '~/lib/utils/poll'; diff --git a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js index 6449272e6ed..1711efb5512 100644 --- a/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js +++ b/spec/frontend/vue_mr_widget/deployment/deployment_actions_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import createFlash from '~/flash'; +import { deprecatedCreateFlash as createFlash } from '~/flash'; import { visitUrl } from '~/lib/utils/url_utility'; import MRWidgetService from '~/vue_merge_request_widget/services/mr_widget_service'; import DeploymentActions from '~/vue_merge_request_widget/components/deployment/deployment_actions.vue'; diff --git a/spec/frontend/vue_mr_widget/mock_data.js b/spec/frontend/vue_mr_widget/mock_data.js index e00456a78b5..d64a7f88b6b 100644 --- a/spec/frontend/vue_mr_widget/mock_data.js +++ b/spec/frontend/vue_mr_widget/mock_data.js @@ -37,6 +37,9 @@ export default { target_project_id: 19, target_project_full_path: '/group2/project2', merge_request_add_ci_config_path: '/group2/project2/new/pipeline', + is_dismissed_suggest_pipeline: false, + user_callouts_path: 'some/callout/path', + suggest_pipeline_feature_id: 'suggest_pipeline', new_project_pipeline_path: '/group2/project2/pipelines/new', metrics: { merged_by: { 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 93659fa54fb..0bbe040d031 100644 --- a/spec/frontend/vue_mr_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_mr_widget/mr_widget_options_spec.js @@ -62,6 +62,9 @@ describe('mrWidgetOptions', () => { return axios.waitForAll(); }; + const findSuggestPipeline = () => vm.$el.querySelector('[data-testid="mr-suggest-pipeline"]'); + const findSuggestPipelineButton = () => findSuggestPipeline().querySelector('button'); + describe('default', () => { beforeEach(() => { return createComponent(); @@ -804,42 +807,48 @@ describe('mrWidgetOptions', () => { }); }); - it('should not suggest pipelines', () => { - vm.mr.mergeRequestAddCiConfigPath = null; - - expect(vm.shouldSuggestPipelines).toBeFalsy(); + it('should not suggest pipelines when feature flag is not present', () => { + expect(findSuggestPipeline()).toBeNull(); }); }); describe('given suggestPipeline feature flag is enabled', () => { beforeEach(() => { + mock.onAny().reply(200); + // This is needed because some grandchildren Bootstrap components throw warnings // https://gitlab.com/gitlab-org/gitlab/issues/208458 jest.spyOn(console, 'warn').mockImplementation(); gon.features = { suggestPipeline: true }; - return createComponent(); - }); - it('should suggest pipelines when none exist', () => { - vm.mr.mergeRequestAddCiConfigPath = 'some/path'; + createComponent(); + vm.mr.hasCI = false; + }); - expect(vm.shouldSuggestPipelines).toBeTruthy(); + it('should suggest pipelines when none exist', () => { + expect(findSuggestPipeline()).toEqual(expect.any(Element)); }); - it('should not suggest pipelines when they exist', () => { - vm.mr.mergeRequestAddCiConfigPath = null; - vm.mr.hasCI = false; + it.each([ + { isDismissedSuggestPipeline: true }, + { mergeRequestAddCiConfigPath: null }, + { hasCI: true }, + ])('with %s, should not suggest pipeline', async obj => { + Object.assign(vm.mr, obj); + + await vm.$nextTick(); - expect(vm.shouldSuggestPipelines).toBeFalsy(); + expect(findSuggestPipeline()).toBeNull(); }); - it('should not suggest pipelines hasCI is true', () => { - vm.mr.mergeRequestAddCiConfigPath = 'some/path'; - vm.mr.hasCI = true; + it('should allow dismiss of the suggest pipeline message', async () => { + findSuggestPipelineButton().click(); + + await vm.$nextTick(); - expect(vm.shouldSuggestPipelines).toBeFalsy(); + expect(findSuggestPipeline()).toBeNull(); }); }); }); 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 1cb2c6c669b..128e0f39c41 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 @@ -11,15 +11,13 @@ describe('getStateKey', () => { hasMergeableDiscussionsState: false, isPipelineBlocked: false, canBeMerged: false, + projectArchived: false, + branchMissing: false, + commitsCount: 2, + hasConflicts: false, + workInProgress: false, }; - const data = { - project_archived: false, - branch_missing: false, - commits_count: 2, - has_conflicts: false, - work_in_progress: false, - }; - const bound = getStateKey.bind(context, data); + const bound = getStateKey.bind(context); expect(bound()).toEqual(null); @@ -49,7 +47,7 @@ describe('getStateKey', () => { expect(bound()).toEqual('unresolvedDiscussions'); - data.work_in_progress = true; + context.workInProgress = true; expect(bound()).toEqual('workInProgress'); @@ -62,7 +60,7 @@ describe('getStateKey', () => { expect(bound()).toEqual('rebase'); - data.has_conflicts = true; + context.hasConflicts = true; expect(bound()).toEqual('conflicts'); @@ -70,15 +68,15 @@ describe('getStateKey', () => { expect(bound()).toEqual('checking'); - data.commits_count = 0; + context.commitsCount = 0; expect(bound()).toEqual('nothingToMerge'); - data.branch_missing = true; + context.branchMissing = true; expect(bound()).toEqual('missingBranch'); - data.project_archived = true; + context.projectArchived = true; expect(bound()).toEqual('archived'); }); @@ -94,15 +92,13 @@ describe('getStateKey', () => { isPipelineBlocked: false, canBeMerged: false, shouldBeRebased: true, + projectArchived: false, + branchMissing: false, + commitsCount: 2, + hasConflicts: false, + workInProgress: false, }; - const data = { - project_archived: false, - branch_missing: false, - commits_count: 2, - has_conflicts: false, - work_in_progress: false, - }; - const bound = getStateKey.bind(context, data); + const bound = getStateKey.bind(context); expect(bound()).toEqual('rebase'); }); @@ -115,15 +111,11 @@ describe('getStateKey', () => { `( 'returns $stateKey when canMerge is $canMerge and isSHAMismatch is $isSHAMismatch', ({ canMerge, isSHAMismatch, stateKey }) => { - const bound = getStateKey.bind( - { - canMerge, - isSHAMismatch, - }, - { - commits_count: 2, - }, - ); + const bound = getStateKey.bind({ + canMerge, + isSHAMismatch, + commitsCount: 2, + }); expect(bound()).toEqual(stateKey); }, 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 408f9d57147..e84eb7789d3 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 @@ -4,6 +4,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <gl-new-dropdown-stub category="primary" headertext="" + right="" size="medium" text="Clone" variant="info" @@ -38,7 +39,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub - category="tertiary" + category="primary" class="d-inline-flex" data-clipboard-text="ssh://foo.bar" data-qa-selector="copy_ssh_url_button" @@ -79,7 +80,7 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` tag="div" > <gl-button-stub - category="tertiary" + category="primary" class="d-inline-flex" data-clipboard-text="http://foo.bar" data-qa-selector="copy_http_url_button" 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 1f54405928b..cd4728baeaa 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,20 +4,22 @@ 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-secondary btn-md" + class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" style="display: none;" type="button" > <!----> <svg - aria-hidden="true" - class="s12 ic-ellipsis_h" + class="gl-icon s16" + data-testid="ellipsis_h-icon" > <use - xlink:href="#ellipsis_h" + href="#ellipsis_h" /> </svg> + + <!----> </button> <!----> @@ -30,20 +32,22 @@ 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-secondary btn-md" + class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" style="" type="button" > <!----> <svg - aria-hidden="true" - class="s12 ic-ellipsis_h" + class="gl-icon s16" + data-testid="ellipsis_h-icon" > <use - xlink:href="#ellipsis_h" + href="#ellipsis_h" /> </svg> + + <!----> </button> </span> `; @@ -52,19 +56,21 @@ 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-secondary btn-md" + class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" type="button" > <!----> <svg - aria-hidden="true" - class="s12 ic-ellipsis_h" + class="gl-icon s16" + data-testid="ellipsis_h-icon" > <use - xlink:href="#ellipsis_h" + href="#ellipsis_h" /> </svg> + + <!----> </button> <span> @@ -77,20 +83,22 @@ 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-secondary btn-md" + class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" style="display: none;" type="button" > <!----> <svg - aria-hidden="true" - class="s12 ic-ellipsis_h" + class="gl-icon s16" + data-testid="ellipsis_h-icon" > <use - xlink:href="#ellipsis_h" + href="#ellipsis_h" /> </svg> + + <!----> </button> </span> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap index 74f71c23d02..fcb9c4b8b02 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/split_button_spec.js.snap @@ -1,13 +1,13 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`SplitButton renders actionItems 1`] = ` -<gl-dropdown-stub +<gl-deprecated-dropdown-stub menu-class="dropdown-menu-selectable " split="true" text="professor" variant="secondary" > - <gl-dropdown-item-stub + <gl-deprecated-dropdown-item-stub active="true" active-class="is-active" > @@ -18,10 +18,10 @@ exports[`SplitButton renders actionItems 1`] = ` <div> very symphonic </div> - </gl-dropdown-item-stub> + </gl-deprecated-dropdown-item-stub> - <gl-dropdown-divider-stub /> - <gl-dropdown-item-stub + <gl-deprecated-dropdown-divider-stub /> + <gl-deprecated-dropdown-item-stub active-class="is-active" > <strong> @@ -31,8 +31,8 @@ exports[`SplitButton renders actionItems 1`] = ` <div> warp drive </div> - </gl-dropdown-item-stub> + </gl-deprecated-dropdown-item-stub> <!----> -</gl-dropdown-stub> +</gl-deprecated-dropdown-stub> `; diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown_spec.js index 38e0cadfe83..d9829874b93 100644 --- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/clone_dropdown_spec.js @@ -1,6 +1,6 @@ -import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue'; import { shallowMount } from '@vue/test-utils'; import { GlFormInputGroup, GlNewDropdownHeader } from '@gitlab/ui'; +import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue'; describe('Clone Dropdown Button', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js index 8d3fcdd48d2..c75891c9ed3 100644 --- a/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/viewers/markdown_viewer_spec.js @@ -1,8 +1,8 @@ import $ from 'jquery'; -import axios from '~/lib/utils/axios_utils'; import MockAdapter from 'axios-mock-adapter'; import { mount } from '@vue/test-utils'; import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; import MarkdownViewer from '~/vue_shared/components/content_viewer/viewers/markdown_viewer.vue'; describe('MarkdownViewer', () => { 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 ceea8d2fa92..223e22d650b 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 @@ -13,9 +13,9 @@ describe('DateTimePicker', () => { const dropdownToggle = () => wrapper.find('.dropdown-toggle'); const dropdownMenu = () => wrapper.find('.dropdown-menu'); + const cancelButton = () => wrapper.find('[data-testid="cancelButton"]'); const applyButtonElement = () => wrapper.find('button.btn-success').element; const findQuickRangeItems = () => wrapper.findAll('.dropdown-item'); - const cancelButtonElement = () => wrapper.find('button.btn-secondary').element; const createComponent = props => { wrapper = mount(DateTimePicker, { @@ -260,7 +260,7 @@ describe('DateTimePicker', () => { dropdownToggle().trigger('click'); return wrapper.vm.$nextTick(() => { - cancelButtonElement().click(); + cancelButton().trigger('click'); return wrapper.vm.$nextTick(() => { expect(dropdownMenu().classes('show')).toBe(false); diff --git a/spec/frontend/vue_shared/components/dismissible_container_spec.js b/spec/frontend/vue_shared/components/dismissible_container_spec.js new file mode 100644 index 00000000000..e49ca1e2285 --- /dev/null +++ b/spec/frontend/vue_shared/components/dismissible_container_spec.js @@ -0,0 +1,58 @@ +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import axios from '~/lib/utils/axios_utils'; +import dismissibleContainer from '~/vue_shared/components/dismissible_container.vue'; + +describe('DismissibleContainer', () => { + let wrapper; + const propsData = { + path: 'some/path', + featureId: 'some-feature-id', + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('template', () => { + const findBtn = () => wrapper.find('[data-testid="close"]'); + let mockAxios; + + beforeEach(() => { + mockAxios = new MockAdapter(axios); + wrapper = shallowMount(dismissibleContainer, { propsData }); + }); + + afterEach(() => { + mockAxios.restore(); + }); + + it('successfully dismisses', () => { + mockAxios.onPost(propsData.path).replyOnce(200); + const button = findBtn(); + + button.trigger('click'); + + expect(wrapper.emitted().dismiss).toBeTruthy(); + }); + }); + + describe('slots', () => { + const slots = { + title: 'Foo Title', + default: 'default slot', + }; + + it.each(Object.keys(slots))('renders the %s slot', slot => { + const slotContent = slots[slot]; + wrapper = shallowMount(dismissibleContainer, { + propsData, + slots: { + [slot]: `<span>${slotContent}</span>`, + }, + }); + + expect(wrapper.text()).toContain(slotContent); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js new file mode 100644 index 00000000000..4c4baf23120 --- /dev/null +++ b/spec/frontend/vue_shared/components/dismissible_feedback_alert_spec.js @@ -0,0 +1,91 @@ +import { mount, shallowMount } from '@vue/test-utils'; +import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; +import Component from '~/vue_shared/components/dismissible_feedback_alert.vue'; + +describe('Dismissible Feedback Alert', () => { + useLocalStorageSpy(); + + let wrapper; + + const defaultProps = { + featureName: 'Dependency List', + feedbackLink: 'https://gitlab.link', + }; + + const STORAGE_DISMISSAL_KEY = 'dependency_list_feedback_dismissed'; + + const createComponent = ({ props, shallow } = {}) => { + const mountFn = shallow ? shallowMount : mount; + + wrapper = mountFn(Component, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlSprintf, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findAlert = () => wrapper.find(GlAlert); + const findLink = () => wrapper.find(GlLink); + + describe('with default', () => { + beforeEach(() => { + createComponent(); + }); + + it('shows alert', () => { + expect(findAlert().exists()).toBe(true); + }); + + it('contains feature name', () => { + expect(findAlert().text()).toContain(defaultProps.featureName); + }); + + it('contains provided link', () => { + const link = findLink(); + + expect(link.attributes('href')).toBe(defaultProps.feedbackLink); + expect(link.attributes('target')).toBe('_blank'); + }); + + it('should have the storage key set', () => { + expect(wrapper.vm.storageKey).toBe(STORAGE_DISMISSAL_KEY); + }); + }); + + describe('dismissible', () => { + describe('after dismissal', () => { + beforeEach(() => { + createComponent({ shallow: false }); + findAlert().vm.$emit('dismiss'); + }); + + it('hides the alert', () => { + expect(findAlert().exists()).toBe(false); + }); + + it('should remember the dismissal state', () => { + expect(localStorage.setItem).toHaveBeenCalledWith(STORAGE_DISMISSAL_KEY, 'true'); + }); + }); + + describe('already dismissed', () => { + it('should not show the alert once dismissed', async () => { + localStorage.setItem(STORAGE_DISMISSAL_KEY, 'true'); + createComponent({ shallow: false }); + await wrapper.vm.$nextTick(); + + expect(findAlert().exists()).toBe(false); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/file_finder/item_spec.js b/spec/frontend/vue_shared/components/file_finder/item_spec.js index 63f2614106d..5a45a5dbba1 100644 --- a/spec/frontend/vue_shared/components/file_finder/item_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/item_spec.js @@ -1,7 +1,7 @@ import Vue from 'vue'; import { file } from 'jest/ide/helpers'; -import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; import createComponent from 'helpers/vue_mount_component_helper'; +import ItemComponent from '~/vue_shared/components/file_finder/item.vue'; describe('File finder item spec', () => { const Component = Vue.extend(ItemComponent); diff --git a/spec/frontend/vue_shared/components/file_icon_spec.js b/spec/frontend/vue_shared/components/file_icon_spec.js index adf0da21f9f..e55449dc684 100644 --- a/spec/frontend/vue_shared/components/file_icon_spec.js +++ b/spec/frontend/vue_shared/components/file_icon_spec.js @@ -36,6 +36,9 @@ describe('File Icon component', () => { fileName | iconName ${'test.js'} | ${'javascript'} ${'test.png'} | ${'image'} + ${'test.PNG'} | ${'image'} + ${'.npmrc'} | ${'npm'} + ${'.Npmrc'} | ${'file'} ${'webpack.js'} | ${'webpack'} `('should render a $iconName icon based on file ending', ({ fileName, iconName }) => { createComponent({ fileName }); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index 46df2d2aaf1..1acd2e05464 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -1,8 +1,8 @@ import { file } from 'jest/ide/helpers'; -import FileRow from '~/vue_shared/components/file_row.vue'; -import FileHeader from '~/vue_shared/components/file_row_header.vue'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; +import FileRow from '~/vue_shared/components/file_row.vue'; +import FileHeader from '~/vue_shared/components/file_row_header.vue'; import { escapeFileUrl } from '~/lib/utils/url_utility'; describe('File row component', () => { 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 05508d14209..73dbecadd89 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,4 +1,4 @@ -import { shallowMount } from '@vue/test-utils'; +import { shallowMount, mount } from '@vue/test-utils'; import { GlFilteredSearch, GlButtonGroup, @@ -16,13 +16,16 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; const createComponent = ({ + shallow = true, namespace = 'gitlab-org/gitlab-test', recentSearchesStorageKey = 'requirements', tokens = mockAvailableTokens, - sortOptions = mockSortOptions, + sortOptions, searchInputPlaceholder = 'Filter requirements', -} = {}) => - shallowMount(FilteredSearchBarRoot, { +} = {}) => { + const mountMethod = shallow ? shallowMount : mount; + + return mountMethod(FilteredSearchBarRoot, { propsData: { namespace, recentSearchesStorageKey, @@ -31,12 +34,13 @@ const createComponent = ({ searchInputPlaceholder, }, }); +}; describe('FilteredSearchBarRoot', () => { let wrapper; beforeEach(() => { - wrapper = createComponent(); + wrapper = createComponent({ sortOptions: mockSortOptions }); }); afterEach(() => { @@ -44,23 +48,38 @@ describe('FilteredSearchBarRoot', () => { }); describe('data', () => { - it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props', () => { + it('initializes `filterValue`, `selectedSortOption` and `selectedSortDirection` data props and displays the sort dropdown', () => { 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); + }); + + it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => { + const wrapperNoSort = createComponent(); + + 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); }); }); describe('computed', () => { describe('tokenSymbols', () => { it('returns a map containing type and symbols from `tokens` prop', () => { - expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@' }); + expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' }); }); }); describe('tokenTitles', () => { it('returns a map containing type and title from `tokens` prop', () => { - expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author' }); + expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' }); }); }); @@ -99,6 +118,29 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending'); }); }); + + describe('filteredRecentSearches', () => { + it('returns array of recent searches filtering out any string type (unsupported) items', async () => { + wrapper.setData({ + recentSearches: [{ foo: 'bar' }, 'foo'], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).toHaveLength(1); + expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' }); + }); + + it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => { + wrapper.setProps({ + recentSearchesStorageKey: '', + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).not.toBeDefined(); + }); + }); }); describe('watchers', () => { @@ -139,6 +181,46 @@ describe('FilteredSearchBarRoot', () => { }); }); + describe('removeQuotesEnclosure', () => { + const mockFilters = [ + { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, + }, + { + type: 'label_name', + value: { + data: '"Documentation Update"', + operator: '=', + }, + }, + '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: '=', + }, + }, + 'foo', + ]); + }); + }); + describe('handleSortOptionClick', () => { it('emits component event `onSort` with selected sort by value', () => { wrapper.vm.handleSortOptionClick(mockSortOptions[1]); @@ -172,9 +254,12 @@ describe('FilteredSearchBarRoot', () => { describe('handleHistoryItemSelected', () => { it('emits `onFilter` event with provided filters param', () => { + jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); + wrapper.vm.handleHistoryItemSelected(mockHistoryItems[0]); expect(wrapper.emitted('onFilter')[0]).toEqual([mockHistoryItems[0]]); + expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockHistoryItems[0]); }); }); @@ -233,10 +318,21 @@ describe('FilteredSearchBarRoot', () => { }); }); + it('calls `blurSearchInput` method to remove focus from filter input field', () => { + jest.spyOn(wrapper.vm, 'blurSearchInput'); + + wrapper.find(GlFilteredSearch).vm.$emit('submit', mockFilters); + + expect(wrapper.vm.blurSearchInput).toHaveBeenCalled(); + }); + it('emits component event `onFilter` with provided filters param', () => { + jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); + wrapper.vm.handleFilterSubmit(mockFilters); expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); + expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters); }); }); }); @@ -260,13 +356,28 @@ describe('FilteredSearchBarRoot', () => { expect(glFilteredSearchEl.props('historyItems')).toEqual(mockHistoryItems); }); + it('renders search history items dropdown with formatting done using token symbols', async () => { + const wrapperFullMount = createComponent({ sortOptions: mockSortOptions, shallow: false }); + wrapperFullMount.vm.recentSearchesStore.addRecentSearch(mockHistoryItems[0]); + + await wrapperFullMount.vm.$nextTick(); + + const searchHistoryItemsEl = wrapperFullMount.findAll( + '.gl-search-box-by-click-menu .gl-search-box-by-click-history-item', + ); + + expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"'); + + wrapperFullMount.destroy(); + }); + it('renders sort dropdown component', () => { expect(wrapper.find(GlButtonGroup).exists()).toBe(true); expect(wrapper.find(GlDropdown).exists()).toBe(true); expect(wrapper.find(GlDropdown).props('text')).toBe(mockSortOptions[0].title); }); - it('renders dropdown items', () => { + it('renders sort dropdown items', () => { const dropdownItemsEl = wrapper.findAll(GlDropdownItem); expect(dropdownItemsEl).toHaveLength(mockSortOptions.length); 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 new file mode 100644 index 00000000000..a857f84adf1 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -0,0 +1,19 @@ +import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; + +describe('Filtered Search Utils', () => { + describe('stripQuotes', () => { + it.each` + inputValue | outputValue + ${'"Foo Bar"'} | ${'Foo Bar'} + ${"'Foo Bar'"} | ${'Foo Bar'} + ${'FooBar'} | ${'FooBar'} + ${"Foo'Bar"} | ${"Foo'Bar"} + ${'Foo"Bar'} | ${'Foo"Bar'} + `( + 'returns string $outputValue when called with string $inputValue', + ({ inputValue, outputValue }) => { + expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue); + }, + ); + }); +}); 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 7e28c4e11e1..dcccb1f49b6 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,5 +1,8 @@ +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 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'; export const mockAuthor1 = { id: 1, @@ -30,6 +33,28 @@ export const mockAuthor3 = { export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; +export const mockRegularMilestone = { + id: 1, + name: '4.0', + title: '4.0', +}; + +export const mockEscapedMilestone = { + id: 3, + name: '5.0 RC1', + title: '5.0 RC1', +}; + +export const mockMilestones = [ + { + id: 2, + name: '5.0', + title: '5.0', + }, + mockRegularMilestone, + mockEscapedMilestone, +]; + export const mockAuthorToken = { type: 'author_username', icon: 'user', @@ -42,7 +67,29 @@ export const mockAuthorToken = { fetchAuthors: Api.projectUsers.bind(Api), }; -export const mockAvailableTokens = [mockAuthorToken]; +export const mockLabelToken = { + type: 'label_name', + icon: 'labels', + title: 'Label', + unique: false, + symbol: '~', + token: LabelToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchLabels: () => Promise.resolve(mockLabels), +}; + +export const mockMilestoneToken = { + type: 'milestone_title', + icon: 'clock', + title: 'Milestone', + unique: true, + symbol: '%', + token: MilestoneToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchMilestones: () => Promise.resolve({ data: mockMilestones }), +}; + +export const mockAvailableTokens = [mockAuthorToken, mockLabelToken]; export const mockHistoryItems = [ [ @@ -53,6 +100,13 @@ export const mockHistoryItems = [ operator: '=', }, }, + { + type: 'label_name', + value: { + data: 'Bug', + operator: '=', + }, + }, 'duo', ], [ 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 45294096eda..160febf9d06 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 @@ -4,7 +4,7 @@ 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 { deprecatedCreateFlash as createFlash } from '~/flash'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import { mockAuthorToken, mockAuthors } from '../mock_data'; 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 new file mode 100644 index 00000000000..0e60ee99327 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -0,0 +1,170 @@ +import { mount } from '@vue/test-utils'; +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + mockRegularLabel, + mockLabels, +} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; +import axios from '~/lib/utils/axios_utils'; + +import { deprecatedCreateFlash as createFlash } from '~/flash'; +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, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs: { + Portal: { + template: '<div><slot></slot></div>', + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + }, + }); + +describe('LabelToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + // Label title with spaces is always enclosed in quotations by component. + wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); + + wrapper.setData({ + labels: mockLabels, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe('"foo label"'); + }); + }); + + describe('activeLabel', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeLabel).toEqual(mockRegularLabel); + }); + }); + + describe('containerStyle', () => { + it('returns object containing `backgroundColor` and `color` properties based on `activeLabel` value', () => { + expect(wrapper.vm.containerStyle).toEqual({ + backgroundColor: mockRegularLabel.color, + color: mockRegularLabel.textColor, + }); + }); + + it('returns empty object when `activeLabel` is not set', async () => { + wrapper.setData({ + labels: [], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.containerStyle).toEqual({}); + }); + }); + }); + + describe('methods', () => { + describe('fetchLabelBySearchTerm', () => { + it('calls `config.fetchLabels` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels'); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchLabels).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `labels` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels').mockResolvedValue(mockLabels); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.labels).toEqual(mockLabels); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching labels.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchLabels').mockRejectedValue({}); + + wrapper.vm.fetchLabelBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); + + wrapper.setData({ + labels: mockLabels, + }); + + 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); // Label, =, "Foo Label" + expect(tokenSegments.at(2).text()).toBe(`~${mockRegularLabel.title}`); // "Foo Label" + expect( + tokenSegments + .at(2) + .find('.gl-token') + .attributes('style'), + ).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);'); + }); + }); +}); 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 new file mode 100644 index 00000000000..de893bf44c8 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -0,0 +1,152 @@ +import { mount } from '@vue/test-utils'; +import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } 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 MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; + +import { + mockMilestoneToken, + mockMilestones, + mockRegularMilestone, + mockEscapedMilestone, +} from '../mock_data'; + +jest.mock('~/flash'); + +const createComponent = ({ + config = mockMilestoneToken, + value = { data: '' }, + active = false, +} = {}) => + mount(MilestoneToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs: { + Portal: { + template: '<div><slot></slot></div>', + }, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, + }, + }); + +describe('MilestoneToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + wrapper = createComponent(); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + // Milestone title with spaces is always enclosed in quotations by component. + wrapper = createComponent({ value: { data: `"${mockEscapedMilestone.title}"` } }); + + wrapper.setData({ + milestones: mockMilestones, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe('"5.0 rc1"'); + }); + }); + + describe('activeMilestone', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeMilestone).toEqual(mockEscapedMilestone); + }); + }); + }); + + describe('methods', () => { + describe('fetchMilestoneBySearchTerm', () => { + it('calls `config.fetchMilestones` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones'); + + wrapper.vm.fetchMilestoneBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchMilestones).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `milestones` when request is successful', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockResolvedValue({ + data: mockMilestones, + }); + + wrapper.vm.fetchMilestoneBySearchTerm(); + + return waitForPromises().then(() => { + expect(wrapper.vm.milestones).toEqual(mockMilestones); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); + + wrapper.vm.fetchMilestoneBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith('There was a problem fetching milestones.'); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchMilestones').mockRejectedValue({}); + + wrapper.vm.fetchMilestoneBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } }); + + wrapper.setData({ + milestones: mockMilestones, + }); + + 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); // Milestone, =, '%"4.0"' + expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1" + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js b/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js index 30e16bd12da..361b162b6a0 100644 --- a/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js +++ b/spec/frontend/vue_shared/components/form/form_footer_actions_spec.js @@ -1,5 +1,5 @@ -import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; import { shallowMount } from '@vue/test-utils'; +import FormFooterActions from '~/vue_shared/components/form/form_footer_actions.vue'; describe('Form Footer Actions', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/form/title_spec.js b/spec/frontend/vue_shared/components/form/title_spec.js index 38ef1bb3aa7..452f3723e76 100644 --- a/spec/frontend/vue_shared/components/form/title_spec.js +++ b/spec/frontend/vue_shared/components/form/title_spec.js @@ -1,5 +1,5 @@ -import TitleField from '~/vue_shared/components/form/title.vue'; import { shallowMount } from '@vue/test-utils'; +import TitleField from '~/vue_shared/components/form/title.vue'; describe('Title edit field', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/header_ci_component_spec.js b/spec/frontend/vue_shared/components/header_ci_component_spec.js index 216563165d6..5233a64ce5e 100644 --- a/spec/frontend/vue_shared/components/header_ci_component_spec.js +++ b/spec/frontend/vue_shared/components/header_ci_component_spec.js @@ -35,7 +35,7 @@ describe('Header CI Component', () => { vm.$destroy(); }); - const findActionButtons = () => vm.$el.querySelector('.header-action-buttons'); + const findActionButtons = () => vm.$el.querySelector('[data-testid="headerButtons"]'); describe('render', () => { beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/icon_spec.js b/spec/frontend/vue_shared/components/icon_spec.js index a448953cc8e..16728e1705a 100644 --- a/spec/frontend/vue_shared/components/icon_spec.js +++ b/spec/frontend/vue_shared/components/icon_spec.js @@ -1,8 +1,8 @@ import Vue from 'vue'; import { mount } from '@vue/test-utils'; import mountComponent from 'helpers/vue_mount_component_helper'; -import Icon from '~/vue_shared/components/icon.vue'; import iconsPath from '@gitlab/svgs/dist/icons.svg'; +import Icon from '~/vue_shared/components/icon.vue'; jest.mock('@gitlab/svgs/dist/icons.svg', () => 'testing'); diff --git a/spec/frontend/vue_shared/components/identicon_spec.js b/spec/frontend/vue_shared/components/identicon_spec.js index 53a55dcd6bd..24fc3713e2b 100644 --- a/spec/frontend/vue_shared/components/identicon_spec.js +++ b/spec/frontend/vue_shared/components/identicon_spec.js @@ -25,7 +25,7 @@ describe('Identicon', () => { }); describe('entity id is a number', () => { - beforeEach(createComponent); + beforeEach(() => createComponent()); it('matches snapshot', () => { expect(wrapper.element).toMatchSnapshot(); diff --git a/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js new file mode 100644 index 00000000000..2f910a10bc6 --- /dev/null +++ b/spec/frontend/vue_shared/components/issuable/issuable_header_warnings_spec.js @@ -0,0 +1,73 @@ +import { shallowMount, createLocalVue } from '@vue/test-utils'; +import Vuex from 'vuex'; +import IssuableHeaderWarnings from '~/vue_shared/components/issuable/issuable_header_warnings.vue'; +import createIssueStore from '~/notes/stores'; +import { createStore as createMrStore } from '~/mr_notes/stores'; + +const ISSUABLE_TYPE_ISSUE = 'issue'; +const ISSUABLE_TYPE_MR = 'merge request'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('IssuableHeaderWarnings', () => { + let wrapper; + let store; + + const findConfidentialIcon = () => wrapper.find('[data-testid="confidential"]'); + const findLockedIcon = () => wrapper.find('[data-testid="locked"]'); + + const renderTestMessage = renders => (renders ? 'renders' : 'does not render'); + + const setLock = locked => { + store.getters.getNoteableData.discussion_locked = locked; + }; + + const setConfidential = confidential => { + store.getters.getNoteableData.confidential = confidential; + }; + + const createComponent = () => { + wrapper = shallowMount(IssuableHeaderWarnings, { store, localVue }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + store = null; + }); + + describe.each` + issuableType + ${ISSUABLE_TYPE_ISSUE} | ${ISSUABLE_TYPE_MR} + `(`when issuableType=$issuableType`, ({ issuableType }) => { + beforeEach(() => { + store = issuableType === ISSUABLE_TYPE_ISSUE ? createIssueStore() : createMrStore(); + createComponent(); + }); + + describe.each` + lockStatus | confidentialStatus + ${true} | ${true} + ${true} | ${false} + ${false} | ${true} + ${false} | ${false} + `( + `when locked=$lockStatus and confidential=$confidentialStatus`, + ({ lockStatus, confidentialStatus }) => { + beforeEach(() => { + setLock(lockStatus); + setConfidential(confidentialStatus); + }); + + it(`${renderTestMessage(lockStatus)} the locked icon`, () => { + expect(findLockedIcon().exists()).toBe(lockStatus); + }); + + it(`${renderTestMessage(confidentialStatus)} the confidential icon`, () => { + expect(findConfidentialIcon().exists()).toBe(confidentialStatus); + }); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 548d4476c0f..192e33d8b00 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; +import { mockAssigneesList } from 'jest/boards/mock_data'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; import IssueAssignees from '~/vue_shared/components/issue/issue_assignees.vue'; -import { mockAssigneesList } from 'jest/boards/mock_data'; const TEST_CSS_CLASSES = 'test-classes'; const TEST_MAX_VISIBLE = 4; @@ -21,6 +21,11 @@ describe('IssueAssigneesComponent', () => { vm = wrapper.vm; }; + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + const findTooltipText = () => wrapper.find('.js-assignee-tooltip').text(); const findAvatars = () => wrapper.findAll(UserAvatarLink); const findOverflowCounter = () => wrapper.find('.avatar-counter'); @@ -123,6 +128,22 @@ describe('IssueAssigneesComponent', () => { it('renders assignee @username', () => { expect(findTooltipText()).toContain('@monserrate.gleichner'); }); + + it('does not render `@` when username not available', () => { + const userName = 'User without username'; + factory({ + assignees: [ + { + name: userName, + }, + ], + }); + + const tooltipText = findTooltipText(); + + expect(tooltipText).toContain(userName); + expect(tooltipText).not.toContain('@'); + }); }); }); }); 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 69d8c1a5918..b72f78c4f60 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -1,11 +1,10 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; +import { mockMilestone } from 'jest/boards/mock_data'; import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; import Icon from '~/vue_shared/components/icon.vue'; -import { mockMilestone } from 'jest/boards/mock_data'; - const createComponent = (milestone = mockMilestone) => { const Component = Vue.extend(IssueMilestone); 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 fe9a5156539..fb9487d0bf8 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 @@ -1,9 +1,9 @@ -import Vue from 'vue'; import { mount } from '@vue/test-utils'; +import { TEST_HOST } from 'jest/helpers/test_constants'; import { formatDate } from '~/lib/utils/datetime_utility'; import RelatedIssuableItem from '~/vue_shared/components/issue/related_issuable_item.vue'; +import IssueDueDate from '~/boards/components/issue_due_date.vue'; import { defaultAssignees, defaultMilestone } from './related_issuable_mock_data'; -import { TEST_HOST } from 'jest/helpers/test_constants'; describe('RelatedIssuableItem', () => { let wrapper; @@ -71,85 +71,65 @@ describe('RelatedIssuableItem', () => { }); describe('token state', () => { - let tokenState; + const tokenState = () => wrapper.find({ ref: 'iconElementXL' }); - beforeEach(done => { + beforeEach(() => { wrapper.setProps({ state: 'opened' }); - - Vue.nextTick(() => { - tokenState = wrapper.find('.issue-token-state-icon-open'); - - done(); - }); }); it('renders if hasState', () => { - expect(tokenState.exists()).toBe(true); + expect(tokenState().exists()).toBe(true); }); it('renders state title', () => { - const stateTitle = tokenState.attributes('title'); + const stateTitle = tokenState().attributes('title'); const formattedCreateDate = formatDate(props.createdAt); expect(stateTitle).toContain('<span class="bold">Opened</span>'); - expect(stateTitle).toContain(`<span class="text-tertiary">${formattedCreateDate}</span>`); }); it('renders aria label', () => { - expect(tokenState.attributes('aria-label')).toEqual('opened'); + expect(tokenState().attributes('aria-label')).toEqual('opened'); }); it('renders open icon when open state', () => { - expect(tokenState.classes('issue-token-state-icon-open')).toBe(true); + expect(tokenState().classes('issue-token-state-icon-open')).toBe(true); }); - it('renders close icon when close state', done => { + it('renders close icon when close state', async () => { wrapper.setProps({ state: 'closed', closedAt: '2018-12-01T00:00:00.00Z', }); + await wrapper.vm.$nextTick(); - Vue.nextTick(() => { - expect(tokenState.classes('issue-token-state-icon-closed')).toBe(true); - - done(); - }); + expect(tokenState().classes('issue-token-state-icon-closed')).toBe(true); }); }); describe('token metadata', () => { - let tokenMetadata; - - beforeEach(done => { - Vue.nextTick(() => { - tokenMetadata = wrapper.find('.item-meta'); - - done(); - }); - }); + const tokenMetadata = () => wrapper.find('.item-meta'); it('renders item path and ID', () => { - const pathAndID = tokenMetadata.find('.item-path-id').text(); + const pathAndID = tokenMetadata() + .find('.item-path-id') + .text(); expect(pathAndID).toContain('gitlab-org/gitlab-test'); expect(pathAndID).toContain('#1'); }); it('renders milestone icon and name', () => { - const milestoneIcon = tokenMetadata.find('.item-milestone svg use'); - const milestoneTitle = tokenMetadata.find('.item-milestone .milestone-title'); + const milestoneIcon = tokenMetadata().find('.item-milestone svg use'); + const milestoneTitle = tokenMetadata().find('.item-milestone .milestone-title'); expect(milestoneIcon.attributes('href')).toContain('clock'); expect(milestoneTitle.text()).toContain('Milestone title'); }); - it('renders due date component', () => { - expect(tokenMetadata.find('.js-due-date-slot').exists()).toBe(true); - }); - - it('renders weight component', () => { - expect(tokenMetadata.find('.js-weight-slot').exists()).toBe(true); + it('renders due date component with correct due date', () => { + expect(wrapper.find(IssueDueDate).props('date')).toBe(props.dueDate); }); }); @@ -163,40 +143,30 @@ describe('RelatedIssuableItem', () => { }); describe('remove button', () => { - let removeBtn; + const removeButton = () => wrapper.find({ ref: 'removeButton' }); - beforeEach(done => { + beforeEach(() => { wrapper.setProps({ canRemove: true }); - Vue.nextTick(() => { - removeBtn = wrapper.find({ ref: 'removeButton' }); - - done(); - }); }); it('renders if canRemove', () => { - expect(removeBtn.exists()).toBe(true); + expect(removeButton().exists()).toBe(true); }); - it('renders disabled button when removeDisabled', done => { - wrapper.vm.removeDisabled = true; - - Vue.nextTick(() => { - expect(removeBtn.attributes('disabled')).toEqual('disabled'); + it('renders disabled button when removeDisabled', async () => { + wrapper.setData({ removeDisabled: true }); + await wrapper.vm.$nextTick(); - done(); - }); + expect(removeButton().attributes('disabled')).toEqual('disabled'); }); - it('triggers onRemoveRequest when clicked', () => { - removeBtn.trigger('click'); + it('triggers onRemoveRequest when clicked', async () => { + removeButton().trigger('click'); + await wrapper.vm.$nextTick(); + const { relatedIssueRemoveRequest } = wrapper.emitted(); - return wrapper.vm.$nextTick().then(() => { - const { relatedIssueRemoveRequest } = wrapper.emitted(); - - expect(relatedIssueRemoveRequest.length).toBe(1); - expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); - }); + expect(relatedIssueRemoveRequest.length).toBe(1); + expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 74be5f8230e..3da0a35f05a 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -1,8 +1,8 @@ import { mount } from '@vue/test-utils'; -import fieldComponent from '~/vue_shared/components/markdown/field.vue'; import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; +import fieldComponent from '~/vue_shared/components/markdown/field.vue'; import axios from '~/lib/utils/axios_utils'; const markdownPreviewPath = `${TEST_HOST}/preview`; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js index 78f27c9948b..16f60b5ff21 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js @@ -5,10 +5,13 @@ import { registerHTMLToMarkdownRenderer, addImage, getMarkdown, + getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; +import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); +jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); describe('Editor Service', () => { let mockInstance; @@ -120,4 +123,25 @@ describe('Editor Service', () => { expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); }); }); + + describe('getEditorOptions', () => { + const externalOptions = { + customRenderers: {}, + }; + const renderer = {}; + + beforeEach(() => { + buildCustomRenderer.mockReturnValueOnce(renderer); + }); + + it('generates a configuration object with a custom HTML renderer and toolbarItems', () => { + expect(getEditorOptions()).toHaveProp('customHTMLRenderer', renderer); + expect(getEditorOptions()).toHaveProp('toolbarItems'); + }); + + it('passes external renderers to the buildCustomRenderers function', () => { + getEditorOptions(externalOptions); + expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js new file mode 100644 index 00000000000..b9b93b274d2 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js @@ -0,0 +1,69 @@ +import Editor from '@toast-ui/editor'; +import { registerHTMLToMarkdownRenderer } from '~/vue_shared/components/rich_content_editor/services/editor_service'; +import buildMarkdownToHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; + +describe('vue_shared/components/rich_content_editor', () => { + let editor; + + const buildEditor = () => { + editor = new Editor({ + el: document.body, + customHTMLRenderer: buildMarkdownToHTMLRenderer(), + }); + + registerHTMLToMarkdownRenderer(editor); + }; + + beforeEach(() => { + buildEditor(); + }); + + describe('HTML to Markdown', () => { + it('uses "-" character list marker in unordered lists', () => { + editor.setHtml('<ul><li>List item 1</li><li>List item 2</li></ul>'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('- List item 1\n- List item 2'); + }); + + it('does not increment the list marker in ordered lists', () => { + editor.setHtml('<ol><li>List item 1</li><li>List item 2</li></ol>'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('1. List item 1\n1. List item 2'); + }); + + it('indents lists using four spaces', () => { + editor.setHtml('<ul><li>List item 1</li><ul><li>List item 2</li></ul></ul>'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('- List item 1\n - List item 2'); + }); + + it('uses * for strong and _ for emphasis text', () => { + editor.setHtml('<strong>strong text</strong><i>emphasis text</i>'); + + const markdown = editor.getMarkdown(); + + expect(markdown).toBe('**strong text**_emphasis text_'); + }); + }); + + describe('Markdown to HTML', () => { + it.each` + input | output + ${'markdown with _emphasized\ntext_'} | ${'<p>markdown with <em>emphasized text</em></p>\n'} + ${'markdown with **strong\ntext**'} | ${'<p>markdown with <strong>strong text</strong></p>\n'} + `( + 'does not transform softbreaks inside (_) and strong (**) nodes into <br/> tags', + ({ input, output }) => { + editor.setMarkdown(input); + + expect(editor.getHtml()).toBe(output); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js index b6ff6aa767c..3d54db7fe5c 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js @@ -2,7 +2,6 @@ import { shallowMount } from '@vue/test-utils'; import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; import { - EDITOR_OPTIONS, EDITOR_TYPES, EDITOR_HEIGHT, EDITOR_PREVIEW_STYLE, @@ -14,6 +13,7 @@ import { removeCustomEventListener, addImage, registerHTMLToMarkdownRenderer, + getEditorOptions, } from '~/vue_shared/components/rich_content_editor/services/editor_service'; jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({ @@ -22,6 +22,7 @@ jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', removeCustomEventListener: jest.fn(), addImage: jest.fn(), registerHTMLToMarkdownRenderer: jest.fn(), + getEditorOptions: jest.fn(), })); describe('Rich Content Editor', () => { @@ -32,13 +33,25 @@ describe('Rich Content Editor', () => { const findEditor = () => wrapper.find({ ref: 'editor' }); const findAddImageModal = () => wrapper.find(AddImageModal); - beforeEach(() => { + const buildWrapper = () => { wrapper = shallowMount(RichContentEditor, { propsData: { content, imageRoot }, }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; }); describe('when content is loaded', () => { + const editorOptions = {}; + + beforeEach(() => { + getEditorOptions.mockReturnValueOnce(editorOptions); + buildWrapper(); + }); + it('renders an editor', () => { expect(findEditor().exists()).toBe(true); }); @@ -47,8 +60,8 @@ describe('Rich Content Editor', () => { expect(findEditor().props().initialValue).toBe(content); }); - it('provides the correct editor options', () => { - expect(findEditor().props().options).toEqual(EDITOR_OPTIONS); + it('provides options generated by the getEditorOptions service', () => { + expect(findEditor().props().options).toBe(editorOptions); }); it('has the correct preview style', () => { @@ -65,6 +78,10 @@ describe('Rich Content Editor', () => { }); describe('when content is changed', () => { + beforeEach(() => { + buildWrapper(); + }); + it('emits an input event with the changed content', () => { const changedMarkdown = '## Changed Markdown'; const getMarkdownMock = jest.fn().mockReturnValueOnce(changedMarkdown); @@ -77,6 +94,10 @@ describe('Rich Content Editor', () => { }); describe('when content is reset', () => { + beforeEach(() => { + buildWrapper(); + }); + it('should reset the content via setMarkdown', () => { const newContent = 'Just the body content excluding the front matter for example'; const mockInstance = { invoke: jest.fn() }; @@ -89,35 +110,33 @@ describe('Rich Content Editor', () => { }); describe('when editor is loaded', () => { - let mockEditorApi; - beforeEach(() => { - mockEditorApi = { eventManager: { addEventType: jest.fn(), listen: jest.fn() } }; - findEditor().vm.$emit('load', mockEditorApi); + buildWrapper(); }); it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { expect(addCustomEventListener).toHaveBeenCalledWith( - mockEditorApi, + wrapper.vm.editorApi, CUSTOM_EVENTS.openAddImageModal, wrapper.vm.onOpenAddImageModal, ); }); it('registers HTML to markdown renderer', () => { - expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(mockEditorApi); + expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); }); }); describe('when editor is destroyed', () => { - it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - const mockEditorApi = { eventManager: { removeEventHandler: jest.fn() } }; + beforeEach(() => { + buildWrapper(); + }); - wrapper.vm.editorApi = mockEditorApi; + it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { wrapper.vm.$destroy(); expect(removeCustomEventListener).toHaveBeenCalledWith( - mockEditorApi, + wrapper.vm.editorApi, CUSTOM_EVENTS.openAddImageModal, wrapper.vm.onOpenAddImageModal, ); @@ -125,6 +144,10 @@ describe('Rich Content Editor', () => { }); describe('add image modal', () => { + beforeEach(() => { + buildWrapper(); + }); + it('renders an addImageModal component', () => { expect(findAddImageModal().exists()).toBe(true); }); 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 0e8610a22f5..a90d3528d60 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 @@ -47,4 +47,87 @@ describe('HTMLToMarkdownRenderer', () => { expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list); }); }); + + describe('UL LI visitor', () => { + it.each` + listItem | unorderedListBulletChar | result | bulletChar + ${'* list item'} | ${undefined} | ${'- list item'} | ${'default'} + ${' - list item'} | ${'*'} | ${' * list item'} | ${'*'} + ${' * list item'} | ${'-'} | ${' - list item'} | ${'-'} + `( + 'uses $bulletChar bullet char in unordered list items when $unorderedListBulletChar is set in config', + ({ listItem, unorderedListBulletChar, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + unorderedListBulletChar, + }); + baseRenderer.convert.mockReturnValueOnce(listItem); + + expect(htmlToMarkdownRenderer['UL LI'](NODE, listItem)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, listItem); + }, + ); + }); + + describe('OL LI visitor', () => { + it.each` + listItem | result | incrementListMarker | action + ${'2. list item'} | ${'1. list item'} | ${false} | ${'increments'} + ${' 3. list item'} | ${' 1. list item'} | ${false} | ${'increments'} + ${' 123. list item'} | ${' 1. list item'} | ${false} | ${'increments'} + ${'3. list item'} | ${'3. list item'} | ${true} | ${'does not increment'} + `( + '$action a list item counter when incrementListMaker is $incrementListMarker', + ({ listItem, result, incrementListMarker }) => { + const subContent = null; + + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + incrementListMarker, + }); + baseRenderer.convert.mockReturnValueOnce(listItem); + + expect(htmlToMarkdownRenderer['OL LI'](NODE, subContent)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, subContent); + }, + ); + }); + + describe('STRONG, B visitor', () => { + it.each` + input | strongCharacter | result + ${'**strong text**'} | ${'_'} | ${'__strong text__'} + ${'__strong text__'} | ${'*'} | ${'**strong text**'} + `( + 'converts $input to $result when strong character is $strongCharacter', + ({ input, strongCharacter, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + strong: strongCharacter, + }); + + baseRenderer.convert.mockReturnValueOnce(input); + + expect(htmlToMarkdownRenderer['STRONG, B'](NODE, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input); + }, + ); + }); + + describe('EM, I visitor', () => { + it.each` + input | emphasisCharacter | result + ${'*strong text*'} | ${'_'} | ${'_strong text_'} + ${'_strong text_'} | ${'*'} | ${'*strong text*'} + `( + 'converts $input to $result when emphasis character is $emphasisCharacter', + ({ input, emphasisCharacter, result }) => { + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { + emphasis: emphasisCharacter, + }); + + baseRenderer.convert.mockReturnValueOnce(input); + + expect(htmlToMarkdownRenderer['EM, I'](NODE, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input); + }, + ); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js index 18dff0a39bb..7a7e3055520 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js @@ -3,7 +3,7 @@ import { buildUneditableOpenTokens, buildUneditableCloseToken, buildUneditableCloseTokens, - buildUneditableTokens, + buildUneditableBlockTokens, buildUneditableInlineTokens, buildUneditableHtmlAsTextTokens, } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; @@ -51,9 +51,9 @@ describe('Build Uneditable Token renderer helper', () => { }); }); - describe('buildUneditableTokens', () => { + describe('buildUneditableBlockTokens', () => { it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { - const result = buildUneditableTokens(originToken); + const result = buildUneditableBlockTokens(originToken); expect(result).toHaveLength(3); expect(result).toStrictEqual(uneditableTokens); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js index b723ee8c8a0..0c59d9f569b 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js @@ -1,5 +1,5 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text'; -import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; +import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode, normalTextNode } from './mock_data'; @@ -17,14 +17,8 @@ describe('Render Embedded Ruby Text renderer', () => { }); describe('render', () => { - const origin = jest.fn(); - - it('should return uneditable tokens', () => { - const context = { origin }; - - expect(renderer.render(embeddedRubyTextNode, context)).toStrictEqual( - buildUneditableTokens(origin()), - ); + 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_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index 320589e4de3..f4a06b91a10 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,8 +1,5 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; -import { - buildUneditableOpenTokens, - buildUneditableCloseToken, -} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; +import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode } from './mock_data'; @@ -40,26 +37,8 @@ describe('Render Identifier Paragraph renderer', () => { }); describe('render', () => { - let origin; - - beforeEach(() => { - origin = jest.fn(); - }); - - it('should return uneditable open tokens when entering', () => { - const context = { entering: true, origin }; - - expect(renderer.render(identifierParagraphNode, context)).toStrictEqual( - buildUneditableOpenTokens(origin()), - ); - }); - - it('should return an uneditable close tokens when exiting', () => { - const context = { entering: false, origin }; - - expect(renderer.render(identifierParagraphNode, context)).toStrictEqual( - buildUneditableCloseToken(origin()), - ); + 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_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js index e60bf1c8c92..7d427108ba6 100644 --- 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 @@ -1,8 +1,5 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list'; -import { - buildUneditableOpenTokens, - buildUneditableCloseToken, -} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; +import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode } from './mock_data'; @@ -34,22 +31,8 @@ describe('Render Kramdown List renderer', () => { }); describe('render', () => { - const origin = jest.fn(); - - it('should return uneditable open tokens when entering', () => { - const context = { entering: true, origin }; - - expect(renderer.render(kramdownListNode, context)).toStrictEqual( - buildUneditableOpenTokens(origin()), - ); - }); - - it('should return an uneditable close tokens when exiting', () => { - const context = { entering: false, origin }; - - expect(renderer.render(kramdownListNode, context)).toStrictEqual( - buildUneditableCloseToken(origin()), - ); + 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 index 97ff9794e69..1d2d152ffc3 100644 --- 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 @@ -1,5 +1,5 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text'; -import { buildUneditableTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; +import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode, normalTextNode } from './mock_data'; @@ -17,14 +17,8 @@ describe('Render Kramdown Text renderer', () => { }); describe('render', () => { - const origin = jest.fn(); - - it('should return uneditable tokens', () => { - const context = { origin }; - - expect(renderer.render(kramdownTextNode, context)).toStrictEqual( - buildUneditableTokens(origin()), - ); + 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_softbreak_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js new file mode 100644 index 00000000000..3c3d2354cb9 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js @@ -0,0 +1,23 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_softbreak'; + +describe('Render softbreak renderer', () => { + describe('canRender', () => { + it.each` + node | parentType | result + ${{ parent: { type: 'emph' } }} | ${'emph'} | ${true} + ${{ parent: { type: 'strong' } }} | ${'strong'} | ${true} + ${{ parent: { type: 'paragraph' } }} | ${'paragraph'} | ${false} + `('returns $result when node parent type is $parentType ', ({ node, result }) => { + expect(renderer.canRender(node)).toBe(result); + }); + }); + + describe('render', () => { + it('returns text node with a break line', () => { + expect(renderer.render()).toEqual({ + type: 'text', + content: ' ', + }); + }); + }); +}); 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 new file mode 100644 index 00000000000..92435b3e4e3 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js @@ -0,0 +1,44 @@ +import { + renderUneditableLeaf, + renderUneditableBranch, +} from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; + +import { + buildUneditableBlockTokens, + buildUneditableOpenTokens, +} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; + +import { originToken, uneditableCloseToken } from './mock_data'; + +describe('Render utils', () => { + describe('renderUneditableLeaf', () => { + it('should return uneditable block tokens around an origin token', () => { + const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; + const result = renderUneditableLeaf({}, context); + + expect(result).toStrictEqual(buildUneditableBlockTokens(originToken)); + }); + }); + + describe('renderUneditableBranch', () => { + let origin; + + beforeEach(() => { + origin = jest.fn().mockReturnValueOnce(originToken); + }); + + it('should return uneditable block open token followed by the origin token when entering', () => { + const context = { entering: true, origin }; + const result = renderUneditableBranch({}, context); + + expect(result).toStrictEqual(buildUneditableOpenTokens(originToken)); + }); + + it('should return uneditable block closing token when exiting', () => { + const context = { entering: false, origin }; + const result = renderUneditableBranch({}, context); + + expect(result).toStrictEqual(uneditableCloseToken); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js index c33cffb421d..53e8a0e1278 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js @@ -1,6 +1,6 @@ import { mount } from '@vue/test-utils'; -import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; import { GlLabel } from '@gitlab/ui'; +import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; import { mockConfig, mockLabels } from './mock_data'; 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 68c9d26bb1a..cb758797c63 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 @@ -41,23 +41,20 @@ describe('DropdownButton', () => { describe('methods', () => { describe('handleButtonClick', () => { it.each` - variant - ${'standalone'} - ${'embedded'} + variant | expectPropagationStopped + ${'standalone'} | ${true} + ${'embedded'} | ${false} `( - 'toggles dropdown content and stops event propagation when `state.variant` is "$variant"', - ({ variant }) => { + 'toggles dropdown content and handles event propagation when `state.variant` is "$variant"', + ({ variant, expectPropagationStopped }) => { const event = { stopPropagation: jest.fn() }; - wrapper = createComponent({ - ...mockConfig, - variant, - }); + wrapper = createComponent({ ...mockConfig, variant }); findDropdownButton().vm.$emit('click', event); expect(store.state.showDropdownContents).toBe(true); - expect(event.stopPropagation).toHaveBeenCalled(); + expect(event.stopPropagation).toHaveBeenCalledTimes(expectPropagationStopped ? 1 : 0); }, ); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js index 9b01e0b9637..589be0ad7a4 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -17,53 +17,47 @@ import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); -const createComponent = (initialState = mockConfig) => { - const store = new Vuex.Store({ - getters, - mutations, - state: { - ...defaultState(), - footerCreateLabelTitle: 'Create label', - footerManageLabelTitle: 'Manage labels', - }, - actions: { - ...actions, - fetchLabels: jest.fn(), - }, - }); - - store.dispatch('setInitialState', initialState); - store.dispatch('receiveLabelsSuccess', mockLabels); - - return shallowMount(DropdownContentsLabelsView, { - localVue, - store, - }); -}; - describe('DropdownContentsLabelsView', () => { let wrapper; - let wrapperStandalone; - let wrapperEmbedded; - beforeEach(() => { - wrapper = createComponent(); - wrapperStandalone = createComponent({ - ...mockConfig, - variant: 'standalone', + const createComponent = (initialState = mockConfig) => { + const store = new Vuex.Store({ + getters, + mutations, + state: { + ...defaultState(), + footerCreateLabelTitle: 'Create label', + footerManageLabelTitle: 'Manage labels', + }, + actions: { + ...actions, + fetchLabels: jest.fn(), + }, }); - wrapperEmbedded = createComponent({ - ...mockConfig, - variant: 'embedded', + + store.dispatch('setInitialState', initialState); + store.dispatch('receiveLabelsSuccess', mockLabels); + + wrapper = shallowMount(DropdownContentsLabelsView, { + localVue, + store, }); + }; + + beforeEach(() => { + createComponent(); }); afterEach(() => { wrapper.destroy(); - wrapperStandalone.destroy(); - wrapperEmbedded.destroy(); + wrapper = null; }); + const findDropdownContent = () => wrapper.find('[data-testid="dropdown-content"]'); + const findDropdownTitle = () => wrapper.find('[data-testid="dropdown-title"]'); + const findDropdownFooter = () => wrapper.find('[data-testid="dropdown-footer"]'); + const findLoadingIcon = () => wrapper.find(GlLoadingIcon); + describe('computed', () => { describe('visibleLabels', () => { it('returns matching labels filtered with `searchKey`', () => { @@ -83,6 +77,24 @@ describe('DropdownContentsLabelsView', () => { expect(wrapper.vm.visibleLabels.length).toBe(mockLabels.length); }); }); + + describe('showListContainer', () => { + it.each` + variant | loading | showList + ${'sidebar'} | ${false} | ${true} + ${'sidebar'} | ${true} | ${false} + ${'not-sidebar'} | ${true} | ${true} + ${'not-sidebar'} | ${false} | ${true} + `( + 'returns $showList if `state.variant` is "$variant" and `labelsFetchInProgress` is $loading', + ({ variant, loading, showList }) => { + createComponent({ ...mockConfig, variant }); + wrapper.vm.$store.state.labelsFetchInProgress = loading; + + expect(wrapper.vm.showListContainer).toBe(showList); + }, + ); + }); }); describe('methods', () => { @@ -199,7 +211,7 @@ describe('DropdownContentsLabelsView', () => { wrapper.vm.$store.dispatch('requestLabels'); return wrapper.vm.$nextTick(() => { - const loadingIconEl = wrapper.find(GlLoadingIcon); + const loadingIconEl = findLoadingIcon(); expect(loadingIconEl.exists()).toBe(true); expect(loadingIconEl.attributes('class')).toContain('labels-fetch-loading'); @@ -207,22 +219,24 @@ describe('DropdownContentsLabelsView', () => { }); it('renders dropdown title element', () => { - const titleEl = wrapper.find('.dropdown-title > span'); + const titleEl = findDropdownTitle(); expect(titleEl.exists()).toBe(true); expect(titleEl.text()).toBe('Assign labels'); }); it('does not render dropdown title element when `state.variant` is "standalone"', () => { - expect(wrapperStandalone.find('.dropdown-title').exists()).toBe(false); + createComponent({ ...mockConfig, variant: 'standalone' }); + expect(findDropdownTitle().exists()).toBe(false); }); it('renders dropdown title element when `state.variant` is "embedded"', () => { - expect(wrapperEmbedded.find('.dropdown-title').exists()).toBe(true); + createComponent({ ...mockConfig, variant: 'embedded' }); + expect(findDropdownTitle().exists()).toBe(true); }); it('renders dropdown close button element', () => { - const closeButtonEl = wrapper.find('.dropdown-title').find(GlButton); + const closeButtonEl = findDropdownTitle().find(GlButton); expect(closeButtonEl.exists()).toBe(true); expect(closeButtonEl.props('icon')).toBe('close'); @@ -249,8 +263,7 @@ describe('DropdownContentsLabelsView', () => { }); return wrapper.vm.$nextTick(() => { - const labelsEl = wrapper.findAll('.dropdown-content li'); - const labelItemEl = labelsEl.at(0).find(LabelItem); + const labelItemEl = findDropdownContent().find(LabelItem); expect(labelItemEl.props('highlight')).toBe(true); }); @@ -262,22 +275,28 @@ describe('DropdownContentsLabelsView', () => { }); return wrapper.vm.$nextTick(() => { - const noMatchEl = wrapper.find('.dropdown-content li'); + const noMatchEl = findDropdownContent().find('li'); expect(noMatchEl.isVisible()).toBe(true); expect(noMatchEl.text()).toContain('No matching results'); }); }); + it('renders empty content while loading', () => { + wrapper.vm.$store.state.labelsFetchInProgress = true; + + return wrapper.vm.$nextTick(() => { + const dropdownContent = findDropdownContent(); + + expect(dropdownContent.exists()).toBe(true); + expect(dropdownContent.isVisible()).toBe(false); + }); + }); + it('renders footer list items', () => { - const createLabelLink = wrapper - .find('.dropdown-footer') - .findAll(GlLink) - .at(0); - const manageLabelsLink = wrapper - .find('.dropdown-footer') - .findAll(GlLink) - .at(1); + const footerLinks = findDropdownFooter().findAll(GlLink); + const createLabelLink = footerLinks.at(0); + const manageLabelsLink = footerLinks.at(1); expect(createLabelLink.exists()).toBe(true); expect(createLabelLink.text()).toBe('Create label'); @@ -289,8 +308,7 @@ describe('DropdownContentsLabelsView', () => { wrapper.vm.$store.state.allowLabelCreate = false; return wrapper.vm.$nextTick(() => { - const createLabelLink = wrapper - .find('.dropdown-footer') + const createLabelLink = findDropdownFooter() .findAll(GlLink) .at(0); @@ -299,11 +317,12 @@ describe('DropdownContentsLabelsView', () => { }); it('does not render footer list items when `state.variant` is "standalone"', () => { - expect(wrapperStandalone.find('.dropdown-footer').exists()).toBe(false); + createComponent({ ...mockConfig, variant: 'standalone' }); + expect(findDropdownFooter().exists()).toBe(false); }); it('renders footer list items when `state.variant` is "embedded"', () => { - expect(wrapperEmbedded.find('.dropdown-footer').exists()).toBe(true); + expect(findDropdownFooter().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js index bb462acf11c..97946993857 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js @@ -10,12 +10,13 @@ import { mockConfig } from './mock_data'; const localVue = createLocalVue(); localVue.use(Vuex); -const createComponent = (initialState = mockConfig) => { +const createComponent = (initialState = mockConfig, propsData = {}) => { const store = new Vuex.Store(labelsSelectModule()); store.dispatch('setInitialState', initialState); return shallowMount(DropdownContents, { + propsData, localVue, store, }); @@ -47,8 +48,15 @@ describe('DropdownContent', () => { }); describe('template', () => { - it('renders component container element with class `labels-select-dropdown-contents`', () => { + it('renders component container element with class `labels-select-dropdown-contents` and no styles', () => { expect(wrapper.attributes('class')).toContain('labels-select-dropdown-contents'); + expect(wrapper.attributes('style')).toBe(undefined); + }); + + it('renders component container element with styles when `renderOnTop` is true', () => { + wrapper = createComponent(mockConfig, { renderOnTop: true }); + + expect(wrapper.attributes('style')).toContain('bottom: 100%'); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js index 0717fd829a0..c1d9be7393c 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js @@ -1,7 +1,7 @@ import Vuex from 'vuex'; import { shallowMount, createLocalVue } from '@vue/test-utils'; -import { GlDeprecatedButton, GlLoadingIcon } from '@gitlab/ui'; +import { GlButton, GlLoadingIcon } from '@gitlab/ui'; import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; @@ -42,7 +42,7 @@ describe('DropdownTitle', () => { }); it('renders edit link', () => { - const editBtnEl = wrapper.find(GlDeprecatedButton); + const editBtnEl = wrapper.find(GlButton); expect(editBtnEl.exists()).toBe(true); expect(editBtnEl.text()).toBe('Edit'); 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 6e97b046be2..a1e0db4d29e 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 @@ -9,9 +9,14 @@ import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dr import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import { isInViewport } from '~/lib/utils/common_utils'; import { mockConfig } from './mock_data'; +jest.mock('~/lib/utils/common_utils', () => ({ + isInViewport: jest.fn().mockReturnValue(true), +})); + const localVue = createLocalVue(); localVue.use(Vuex); @@ -21,6 +26,9 @@ const createComponent = (config = mockConfig, slots = {}) => slots, store: new Vuex.Store(labelsSelectModule()), propsData: config, + stubs: { + 'dropdown-contents': DropdownContents, + }, }); describe('LabelsSelectRoot', () => { @@ -144,5 +152,42 @@ describe('LabelsSelectRoot', () => { expect(wrapper.find(DropdownContents).exists()).toBe(true); }); }); + + describe('sets content direction based on viewport', () => { + it('does not set direction when `state.variant` is not "embedded"', () => { + wrapper.vm.$store.dispatch('toggleDropdownContents'); + + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); + }); + }); + + describe('when `state.variant` is "embedded"', () => { + beforeEach(() => { + wrapper = createComponent({ ...mockConfig, variant: 'embedded' }); + wrapper.vm.$store.dispatch('toggleDropdownContents'); + }); + + it('set direction when out of viewport', () => { + isInViewport.mockImplementation(() => false); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(true); + }); + }); + + it('does not set direction when inside of viewport', () => { + isInViewport.mockImplementation(() => true); + wrapper.vm.setContentIsOnViewport(wrapper.vm.$store.state); + + return wrapper.vm.$nextTick().then(() => { + expect(wrapper.find(DropdownContents).props('renderOnTop')).toBe(false); + }); + }); + }); + }); }); }); 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 072d8fe2fe2..c742220ba8a 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -1,10 +1,10 @@ import MockAdapter from 'axios-mock-adapter'; +import testAction from 'helpers/vuex_action_helper'; import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; -import testAction from 'helpers/vuex_action_helper'; import axios from '~/lib/utils/axios_utils'; describe('LabelsSelect Actions', () => { diff --git a/spec/frontend/vue_shared/components/split_button_spec.js b/spec/frontend/vue_shared/components/split_button_spec.js index e09bc073042..f3bd4c14717 100644 --- a/spec/frontend/vue_shared/components/split_button_spec.js +++ b/spec/frontend/vue_shared/components/split_button_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlDeprecatedDropdown, GlDeprecatedDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import SplitButton from '~/vue_shared/components/split_button.vue'; @@ -25,10 +25,10 @@ describe('SplitButton', () => { }); }; - const findDropdown = () => wrapper.find(GlDropdown); + const findDropdown = () => wrapper.find(GlDeprecatedDropdown); const findDropdownItem = (index = 0) => findDropdown() - .findAll(GlDropdownItem) + .findAll(GlDeprecatedDropdownItem) .at(index); const selectItem = index => { findDropdownItem(index).vm.$emit('click'); diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js index 56ffffc7f0f..ef3ae088eec 100644 --- a/spec/frontend/vue_shared/components/table_pagination_spec.js +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; import { GlPagination } from '@gitlab/ui'; +import TablePagination from '~/vue_shared/components/pagination/table_pagination.vue'; describe('Pagination component', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js index 46fcb92455b..691e19473c1 100644 --- a/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js +++ b/spec/frontend/vue_shared/components/time_ago_tooltip_spec.js @@ -1,16 +1,19 @@ import { shallowMount } from '@vue/test-utils'; -import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; + import { formatDate, getTimeago } from '~/lib/utils/datetime_utility'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; describe('Time ago with tooltip component', () => { let vm; - const buildVm = (propsData = {}) => { + const buildVm = (propsData = {}, scopedSlots = {}) => { vm = shallowMount(TimeAgoTooltip, { propsData, + scopedSlots, }); }; const timestamp = '2017-05-08T14:57:39.781Z'; + const timeAgoTimestamp = getTimeago().format(timestamp); afterEach(() => { vm.destroy(); @@ -20,10 +23,9 @@ describe('Time ago with tooltip component', () => { buildVm({ time: timestamp, }); - const timeago = getTimeago(); expect(vm.attributes('title')).toEqual(formatDate(timestamp)); - expect(vm.text()).toEqual(timeago.format(timestamp)); + expect(vm.text()).toEqual(timeAgoTimestamp); }); it('should render provided html class', () => { @@ -34,4 +36,16 @@ describe('Time ago with tooltip component', () => { expect(vm.classes()).toContain('foo'); }); + + it('should render with the datetime attribute', () => { + buildVm({ time: timestamp }); + + expect(vm.attributes('datetime')).toEqual(timestamp); + }); + + it('should render provided scope content with the correct timeAgo string', () => { + buildVm({ time: timestamp }, { default: `<span>The time is {{ props.timeAgo }}</span>` }); + + expect(vm.text()).toEqual(`The time is ${timeAgoTimestamp}`); + }); }); diff --git a/spec/frontend/vue_shared/components/toggle_button_spec.js b/spec/frontend/vue_shared/components/toggle_button_spec.js index 83bbb37a89a..f58647ff12b 100644 --- a/spec/frontend/vue_shared/components/toggle_button_spec.js +++ b/spec/frontend/vue_shared/components/toggle_button_spec.js @@ -32,7 +32,7 @@ describe('Toggle Button', () => { it('renders input status icon', () => { expect(vm.$el.querySelectorAll('span.toggle-icon').length).toEqual(1); - expect(vm.$el.querySelectorAll('svg.s16.toggle-icon-svg').length).toEqual(1); + expect(vm.$el.querySelectorAll('svg.s18').length).toEqual(1); }); }); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js index 1db1114f9ba..6f66d1cafb9 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_list_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDeprecatedButton } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import { TEST_HOST } from 'spec/test_constants'; import UserAvatarList from '~/vue_shared/components/user_avatar/user_avatar_list.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; @@ -37,7 +37,7 @@ describe('UserAvatarList', () => { }; const clickButton = () => { - const button = wrapper.find(GlDeprecatedButton); + const button = wrapper.find(GlButton); button.vm.$emit('click'); }; @@ -112,7 +112,7 @@ describe('UserAvatarList', () => { it('does not show button', () => { factory(); - expect(wrapper.find(GlDeprecatedButton).exists()).toBe(false); + expect(wrapper.find(GlButton).exists()).toBe(false); }); }); diff --git a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js index 90530b7d5c2..1c9e89f99e9 100644 --- a/spec/frontend/vue_shared/directives/autofocusonshow_spec.js +++ b/spec/frontend/vue_shared/directives/autofocusonshow_spec.js @@ -1,3 +1,4 @@ +import { useMockIntersectionObserver } from 'helpers/mock_dom_observer'; import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; /** @@ -6,20 +7,14 @@ import autofocusonshow from '~/vue_shared/directives/autofocusonshow'; * on underlying DOM methods. */ describe('AutofocusOnShow directive', () => { + useMockIntersectionObserver(); + describe('with input invisible on component render', () => { let el; beforeEach(() => { setFixtures('<div id="container" style="display: none;"><input id="inputel"/></div>'); el = document.querySelector('#inputel'); - - window.IntersectionObserver = class { - observe = jest.fn(); - }; - }); - - afterEach(() => { - delete window.IntersectionObserver; }); it('should bind IntersectionObserver on input element', () => { diff --git a/spec/frontend/whats_new/components/app_spec.js b/spec/frontend/whats_new/components/app_spec.js new file mode 100644 index 00000000000..a349aad9f1c --- /dev/null +++ b/spec/frontend/whats_new/components/app_spec.js @@ -0,0 +1,57 @@ +import { createLocalVue, mount } from '@vue/test-utils'; +import Vuex from 'vuex'; +import { GlDrawer } from '@gitlab/ui'; +import App from '~/whats_new/components/app.vue'; + +const localVue = createLocalVue(); +localVue.use(Vuex); + +describe('App', () => { + let wrapper; + let store; + let actions; + let state; + + beforeEach(() => { + actions = { + closeDrawer: jest.fn(), + }; + + state = { + open: true, + }; + + store = new Vuex.Store({ + actions, + state, + }); + + wrapper = mount(App, { + localVue, + store, + }); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const getDrawer = () => wrapper.find(GlDrawer); + + it('contains a drawer', () => { + expect(getDrawer().exists()).toBe(true); + }); + + it('dispatches closeDrawer when clicking close', () => { + getDrawer().vm.$emit('close'); + expect(actions.closeDrawer).toHaveBeenCalled(); + }); + + it.each([true, false])('passes open property', async openState => { + wrapper.vm.$store.state.open = openState; + + await wrapper.vm.$nextTick(); + + expect(getDrawer().props('open')).toBe(openState); + }); +}); diff --git a/spec/frontend/whats_new/components/trigger_spec.js b/spec/frontend/whats_new/components/trigger_spec.js new file mode 100644 index 00000000000..7961957e077 --- /dev/null +++ b/spec/frontend/whats_new/components/trigger_spec.js @@ -0,0 +1,43 @@ +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(); + }); +}); diff --git a/spec/frontend/whats_new/store/actions_spec.js b/spec/frontend/whats_new/store/actions_spec.js new file mode 100644 index 00000000000..d95453c9175 --- /dev/null +++ b/spec/frontend/whats_new/store/actions_spec.js @@ -0,0 +1,17 @@ +import testAction from 'helpers/vuex_action_helper'; +import actions from '~/whats_new/store/actions'; +import * as types from '~/whats_new/store/mutation_types'; + +describe('whats new actions', () => { + describe('openDrawer', () => { + it('should commit openDrawer', () => { + testAction(actions.openDrawer, {}, {}, [{ type: types.OPEN_DRAWER }]); + }); + }); + + describe('closeDrawer', () => { + it('should commit closeDrawer', () => { + testAction(actions.closeDrawer, {}, {}, [{ type: types.CLOSE_DRAWER }]); + }); + }); +}); diff --git a/spec/frontend/whats_new/store/mutations_spec.js b/spec/frontend/whats_new/store/mutations_spec.js new file mode 100644 index 00000000000..3c33364fed3 --- /dev/null +++ b/spec/frontend/whats_new/store/mutations_spec.js @@ -0,0 +1,25 @@ +import mutations from '~/whats_new/store/mutations'; +import createState from '~/whats_new/store/state'; +import * as types from '~/whats_new/store/mutation_types'; + +describe('whats new mutations', () => { + let state; + + beforeEach(() => { + state = createState; + }); + + describe('openDrawer', () => { + it('sets open to true', () => { + mutations[types.OPEN_DRAWER](state); + expect(state.open).toBe(true); + }); + }); + + describe('closeDrawer', () => { + it('sets open to false', () => { + mutations[types.CLOSE_DRAWER](state); + expect(state.open).toBe(false); + }); + }); +}); |