diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 14:22:11 +0000 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2022-12-20 14:22:11 +0000 |
commit | 0c872e02b2c822e3397515ec324051ff540f0cd5 (patch) | |
tree | ce2fb6ce7030e4dad0f4118d21ab6453e5938cdd /spec/frontend | |
parent | f7e05a6853b12f02911494c4b3fe53d9540d74fc (diff) | |
download | gitlab-ce-0c872e02b2c822e3397515ec324051ff540f0cd5.tar.gz |
Add latest changes from gitlab-org/gitlab@15-7-stable-eev15.7.0-rc42
Diffstat (limited to 'spec/frontend')
508 files changed, 12531 insertions, 5709 deletions
diff --git a/spec/frontend/__helpers__/dom_events_helper.js b/spec/frontend/__helpers__/dom_events_helper.js deleted file mode 100644 index 865ea97903f..00000000000 --- a/spec/frontend/__helpers__/dom_events_helper.js +++ /dev/null @@ -1,8 +0,0 @@ -export const triggerDOMEvent = (type) => { - window.document.dispatchEvent( - new Event(type, { - bubbles: true, - cancelable: true, - }), - ); -}; diff --git a/spec/frontend/__helpers__/filtered_search_spec_helper.js b/spec/frontend/__helpers__/filtered_search_spec_helper.js index ecf10694a16..f76fdfca229 100644 --- a/spec/frontend/__helpers__/filtered_search_spec_helper.js +++ b/spec/frontend/__helpers__/filtered_search_spec_helper.js @@ -1,3 +1,5 @@ +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; + export default class FilteredSearchSpecHelper { static createFilterVisualTokenHTML(name, operator, value, isSelected) { return FilteredSearchSpecHelper.createFilterVisualToken(name, operator, value, isSelected) @@ -43,7 +45,7 @@ export default class FilteredSearchSpecHelper { static createSearchVisualToken(name) { const li = document.createElement('li'); - li.classList.add('js-visual-token', 'filtered-search-term'); + li.classList.add('js-visual-token', FILTERED_SEARCH_TERM); li.innerHTML = `<div class="name">${name}</div>`; return li; } diff --git a/spec/frontend/__helpers__/graphql_helpers.js b/spec/frontend/__helpers__/graphql_helpers.js deleted file mode 100644 index 63123aa046f..00000000000 --- a/spec/frontend/__helpers__/graphql_helpers.js +++ /dev/null @@ -1,14 +0,0 @@ -/** - * Returns a clone of the given object with all __typename keys omitted, - * including deeply nested ones. - * - * Only works with JSON-serializable objects. - * - * @param {object} An object with __typename keys (e.g., a GraphQL response) - * @returns {object} A new object with no __typename keys - */ -export const stripTypenames = (object) => { - return JSON.parse( - JSON.stringify(object, (key, value) => (key === '__typename' ? undefined : value)), - ); -}; diff --git a/spec/frontend/__helpers__/graphql_helpers_spec.js b/spec/frontend/__helpers__/graphql_helpers_spec.js deleted file mode 100644 index dd23fbbf4e9..00000000000 --- a/spec/frontend/__helpers__/graphql_helpers_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import { stripTypenames } from './graphql_helpers'; - -describe('stripTypenames', () => { - it.each` - input | expected - ${{}} | ${{}} - ${{ __typename: 'Foo' }} | ${{}} - ${{ bar: 'bar', __typename: 'Foo' }} | ${{ bar: 'bar' }} - ${{ bar: { __typename: 'Bar' }, __typename: 'Foo' }} | ${{ bar: {} }} - ${{ bar: [{ __typename: 'Bar' }], __typename: 'Foo' }} | ${{ bar: [{}] }} - ${[]} | ${[]} - ${[{ __typename: 'Foo' }]} | ${[{}]} - ${[{ bar: [{ a: 1, __typename: 'Bar' }] }]} | ${[{ bar: [{ a: 1 }] }]} - `('given $input returns $expected, with all __typename keys removed', ({ input, expected }) => { - const actual = stripTypenames(input); - expect(actual).toEqual(expected); - expect(input).not.toBe(actual); - }); - - it('given null returns null', () => { - expect(stripTypenames(null)).toEqual(null); - }); -}); diff --git a/spec/frontend/__helpers__/graphql_transformer.js b/spec/frontend/__helpers__/graphql_transformer.js index e776e2ea6ac..f26b63dadfd 100644 --- a/spec/frontend/__helpers__/graphql_transformer.js +++ b/spec/frontend/__helpers__/graphql_transformer.js @@ -3,6 +3,8 @@ const loader = require('graphql-tag/loader'); module.exports = { process(src) { - return loader.call({ cacheable() {} }, src); + return { + code: loader.call({ cacheable() {} }, src), + }; }, }; diff --git a/spec/frontend/__helpers__/jest_helpers.js b/spec/frontend/__helpers__/jest_helpers.js deleted file mode 100644 index 273d2c91966..00000000000 --- a/spec/frontend/__helpers__/jest_helpers.js +++ /dev/null @@ -1,22 +0,0 @@ -/* -@module - -This method provides convenience functions to help migrating from Karma/Jasmine to Jest. - -Try not to use these in new tests - this module is provided primarily for convenience of migrating tests. - */ - -/** - * Creates a plain JS object pre-populated with Jest spy functions. Useful for making simple mocks classes. - * - * @see https://jasmine.github.io/2.0/introduction.html#section-Spies:_%3Ccode%3EcreateSpyObj%3C/code%3E - * @param {string} baseName Human-readable name of the object. This is used for reporting purposes. - * @param methods {string[]} List of method names that will be added to the spy object. - */ -export function createSpyObj(baseName, methods) { - const obj = {}; - methods.forEach((method) => { - obj[method] = jest.fn().mockName(`${baseName}#${method}`); - }); - return obj; -} diff --git a/spec/frontend/__helpers__/mock_window_location_helper.js b/spec/frontend/__helpers__/mock_window_location_helper.js index 14082857053..de1e8c99b54 100644 --- a/spec/frontend/__helpers__/mock_window_location_helper.js +++ b/spec/frontend/__helpers__/mock_window_location_helper.js @@ -21,18 +21,31 @@ const useMockLocation = (fn) => { afterEach(() => { currentWindowLocation = origWindowLocation; }); + + return () => { + beforeEach(() => { + currentWindowLocation = origWindowLocation; + }); + }; }; /** * Create an object with the location interface but `jest.fn()` implementations. */ export const createWindowLocationSpy = () => { - return { + const { origin, href } = window.location; + + const mockLocation = { assign: jest.fn(), reload: jest.fn(), replace: jest.fn(), toString: jest.fn(), + origin, + // TODO: Do we need to update `origin` if `href` is changed? + href, }; + + return mockLocation; }; /** diff --git a/spec/frontend/__helpers__/raw_transformer.js b/spec/frontend/__helpers__/raw_transformer.js index 09101b7a64f..3b0bed14e8d 100644 --- a/spec/frontend/__helpers__/raw_transformer.js +++ b/spec/frontend/__helpers__/raw_transformer.js @@ -1,6 +1,6 @@ /* eslint-disable import/no-commonjs */ module.exports = { process: (content) => { - return `module.exports = ${JSON.stringify(content)}`; + return { code: `module.exports = ${JSON.stringify(content)}` }; }, }; diff --git a/spec/frontend/__helpers__/set_timeout_promise_helper.js b/spec/frontend/__helpers__/set_timeout_promise_helper.js deleted file mode 100644 index afd18d92d15..00000000000 --- a/spec/frontend/__helpers__/set_timeout_promise_helper.js +++ /dev/null @@ -1,4 +0,0 @@ -export default (time = 0) => - new Promise((resolve) => { - setTimeout(resolve, time); - }); diff --git a/spec/frontend/__helpers__/web_worker_transformer.js b/spec/frontend/__helpers__/web_worker_transformer.js index 767ab3f5675..86be856f7b7 100644 --- a/spec/frontend/__helpers__/web_worker_transformer.js +++ b/spec/frontend/__helpers__/web_worker_transformer.js @@ -1,18 +1,22 @@ /* eslint-disable import/no-commonjs */ -const babelJestTransformer = require('babel-jest'); +const { createTransformer } = require('babel-jest'); // This Jest will transform the code of a WebWorker module into a FakeWebWorker subclass. // This is meant to mirror Webpack's [`worker-loader`][1]. // [1]: https://webpack.js.org/loaders/worker-loader/ module.exports = { process: (contentArg, filename, ...args) => { - const { code: content } = babelJestTransformer.default.process(contentArg, filename, ...args); + const { code: content } = createTransformer().process(contentArg, filename, ...args); - return `const { FakeWebWorker } = require("helpers/web_worker_fake"); + const jestTransformedWorkerCode = `const { FakeWebWorker } = require("helpers/web_worker_fake"); module.exports = class JestTransformedWorker extends FakeWebWorker { constructor() { super(${JSON.stringify(filename)}, ${JSON.stringify(content)}); } };`; + + return { + code: jestTransformedWorkerCode, + }; }, }; diff --git a/spec/frontend/__helpers__/yaml_transformer.js b/spec/frontend/__helpers__/yaml_transformer.js index a23f9b1f715..e0b4d01f542 100644 --- a/spec/frontend/__helpers__/yaml_transformer.js +++ b/spec/frontend/__helpers__/yaml_transformer.js @@ -6,6 +6,6 @@ module.exports = { process: (sourceContent) => { const jsonContent = JsYaml.load(sourceContent); const json = JSON.stringify(jsonContent); - return `module.exports = ${json}`; + return { code: `module.exports = ${json}` }; }, }; diff --git a/spec/frontend/__mocks__/@gitlab/ui.js b/spec/frontend/__mocks__/@gitlab/ui.js index 6f2888e5c42..4d893bcd0bd 100644 --- a/spec/frontend/__mocks__/@gitlab/ui.js +++ b/spec/frontend/__mocks__/@gitlab/ui.js @@ -49,6 +49,8 @@ jest.mock('@gitlab/ui/dist/components/base/popover/popover.js', () => ({ 'boundary', 'container', 'showCloseButton', + 'show', + 'boundaryPadding', ].map((prop) => [prop, {}]), ), }, diff --git a/spec/frontend/admin/background_migrations/components/database_listbox_spec.js b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js index 3778943872e..212f4c0842c 100644 --- a/spec/frontend/admin/background_migrations/components/database_listbox_spec.js +++ b/spec/frontend/admin/background_migrations/components/database_listbox_spec.js @@ -1,4 +1,4 @@ -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import BackgroundMigrationsDatabaseListbox from '~/admin/background_migrations/components/database_listbox.vue'; import { visitUrl, setUrlParams } from '~/lib/utils/url_utility'; @@ -30,15 +30,15 @@ describe('BackgroundMigrationsDatabaseListbox', () => { wrapper.destroy(); }); - const findGlListbox = () => wrapper.findComponent(GlListbox); + const findGlCollapsibleListbox = () => wrapper.findComponent(GlCollapsibleListbox); describe('template always', () => { beforeEach(() => { createComponent(); }); - it('renders GlListbox', () => { - expect(findGlListbox().exists()).toBe(true); + it('renders GlCollapsibleListbox', () => { + expect(findGlCollapsibleListbox().exists()).toBe(true); }); }); @@ -48,7 +48,7 @@ describe('BackgroundMigrationsDatabaseListbox', () => { }); it('selecting a listbox item fires visitUrl with the database param', () => { - findGlListbox().vm.$emit('select', MOCK_DATABASES[1].value); + findGlCollapsibleListbox().vm.$emit('select', MOCK_DATABASES[1].value); expect(setUrlParams).toHaveBeenCalledWith({ database: MOCK_DATABASES[1].value }); expect(visitUrl).toHaveBeenCalled(); diff --git a/spec/frontend/admin/broadcast_messages/components/datetime_picker_spec.js b/spec/frontend/admin/broadcast_messages/components/datetime_picker_spec.js new file mode 100644 index 00000000000..291c3aed1cf --- /dev/null +++ b/spec/frontend/admin/broadcast_messages/components/datetime_picker_spec.js @@ -0,0 +1,46 @@ +import { mount } from '@vue/test-utils'; +import { GlDatepicker } from '@gitlab/ui'; +import DatetimePicker from '~/admin/broadcast_messages/components/datetime_picker.vue'; + +describe('DatetimePicker', () => { + let wrapper; + + const toDate = (day, time) => new Date(`${day}T${time}:00.000Z`); + const findDatepicker = () => wrapper.findComponent(GlDatepicker); + const findTimepicker = () => wrapper.findComponent('[data-testid="time-picker"]'); + + const testDay = '2022-03-22'; + const testTime = '01:23'; + + function createComponent() { + wrapper = mount(DatetimePicker, { + propsData: { + value: toDate(testDay, testTime), + }, + }); + } + + beforeEach(() => { + createComponent(); + }); + + it('renders the Date in the datepicker and timepicker inputs', () => { + expect(findDatepicker().props().value).toEqual(toDate(testDay, testTime)); + expect(findTimepicker().element.value).toEqual(testTime); + }); + + it('emits Date with the new day/old time when the date picker changes', () => { + const newDay = '1992-06-30'; + const newTime = '08:00'; + + findDatepicker().vm.$emit('input', toDate(newDay, newTime)); + expect(wrapper.emitted().input).toEqual([[toDate(newDay, testTime)]]); + }); + + it('emits Date with the old day/new time when the time picker changes', () => { + const newTime = '08:00'; + + findTimepicker().vm.$emit('input', newTime); + expect(wrapper.emitted().input).toEqual([[toDate(testDay, newTime)]]); + }); +}); diff --git a/spec/frontend/admin/broadcast_messages/components/message_form_spec.js b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js new file mode 100644 index 00000000000..88ea79f38b3 --- /dev/null +++ b/spec/frontend/admin/broadcast_messages/components/message_form_spec.js @@ -0,0 +1,201 @@ +import { mount } from '@vue/test-utils'; +import { GlBroadcastMessage, GlForm } from '@gitlab/ui'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import { createAlert } from '~/flash'; +import axios from '~/lib/utils/axios_utils'; +import httpStatus from '~/lib/utils/http_status'; +import MessageForm from '~/admin/broadcast_messages/components/message_form.vue'; +import { + BROADCAST_MESSAGES_PATH, + TYPE_BANNER, + TYPE_NOTIFICATION, + THEMES, +} from '~/admin/broadcast_messages/constants'; +import waitForPromises from 'helpers/wait_for_promises'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { MOCK_TARGET_ACCESS_LEVELS } from '../mock_data'; + +jest.mock('~/flash'); + +describe('MessageForm', () => { + let wrapper; + let axiosMock; + + const defaultProps = { + message: 'zzzzzzz', + broadcastType: TYPE_BANNER, + theme: THEMES[0].value, + dismissable: false, + targetPath: '', + targetAccessLevels: [], + startsAt: new Date(), + endsAt: new Date(), + }; + + const findPreview = () => extendedWrapper(wrapper.findComponent(GlBroadcastMessage)); + const findThemeSelect = () => wrapper.findComponent('[data-testid=theme-select]'); + const findDismissable = () => wrapper.findComponent('[data-testid=dismissable-checkbox]'); + const findTargetRoles = () => wrapper.findComponent('[data-testid=target-roles-checkboxes]'); + const findSubmitButton = () => wrapper.findComponent('[data-testid=submit-button]'); + const findForm = () => wrapper.findComponent(GlForm); + + function createComponent({ broadcastMessage = {}, glFeatures = {} }) { + wrapper = mount(MessageForm, { + provide: { + glFeatures, + targetAccessLevelOptions: MOCK_TARGET_ACCESS_LEVELS, + }, + propsData: { + broadcastMessage: { + ...defaultProps, + ...broadcastMessage, + }, + }, + }); + } + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + }); + + afterEach(() => { + axiosMock.restore(); + createAlert.mockClear(); + }); + + describe('the message preview', () => { + it('renders the preview with the user selected theme', () => { + const theme = 'blue'; + createComponent({ broadcastMessage: { theme } }); + expect(findPreview().props().theme).toEqual(theme); + }); + + it('renders the placeholder text when the user message is blank', () => { + createComponent({ broadcastMessage: { message: ' ' } }); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.messagePlaceholder); + }); + }); + + describe('theme select dropdown', () => { + it('renders for Banners', () => { + createComponent({ broadcastMessage: { broadcastType: TYPE_BANNER } }); + expect(findThemeSelect().exists()).toBe(true); + }); + + it('does not render for Notifications', () => { + createComponent({ broadcastMessage: { broadcastType: TYPE_NOTIFICATION } }); + expect(findThemeSelect().exists()).toBe(false); + }); + }); + + describe('dismissable checkbox', () => { + it('renders for Banners', () => { + createComponent({ broadcastMessage: { broadcastType: TYPE_BANNER } }); + expect(findDismissable().exists()).toBe(true); + }); + + it('does not render for Notifications', () => { + createComponent({ broadcastMessage: { broadcastType: TYPE_NOTIFICATION } }); + expect(findDismissable().exists()).toBe(false); + }); + }); + + describe('target roles checkboxes', () => { + it('renders when roleTargetedBroadcastMessages feature is enabled', () => { + createComponent({ glFeatures: { roleTargetedBroadcastMessages: true } }); + expect(findTargetRoles().exists()).toBe(true); + }); + + it('does not render when roleTargetedBroadcastMessages feature is disabled', () => { + createComponent({ glFeatures: { roleTargetedBroadcastMessages: false } }); + expect(findTargetRoles().exists()).toBe(false); + }); + }); + + describe('form submit button', () => { + it('renders the "add" text when the message is not persisted', () => { + createComponent({ broadcastMessage: { id: undefined } }); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.add); + }); + + it('renders the "update" text when the message is persisted', () => { + createComponent({ broadcastMessage: { id: 100 } }); + expect(wrapper.text()).toContain(wrapper.vm.$options.i18n.update); + }); + + it('is disabled when the user message is blank', () => { + createComponent({ broadcastMessage: { message: ' ' } }); + expect(findSubmitButton().props().disabled).toBe(true); + }); + + it('is not disabled when the user message is present', () => { + createComponent({ broadcastMessage: { message: 'alsdjfkldsj' } }); + expect(findSubmitButton().props().disabled).toBe(false); + }); + }); + + describe('form submission', () => { + const defaultPayload = { + message: defaultProps.message, + broadcast_type: defaultProps.broadcastType, + theme: defaultProps.theme, + dismissable: defaultProps.dismissable, + target_path: defaultProps.targetPath, + target_access_levels: defaultProps.targetAccessLevels, + starts_at: defaultProps.startsAt, + ends_at: defaultProps.endsAt, + }; + + it('sends a create request for a new message form', async () => { + createComponent({ broadcastMessage: { id: undefined } }); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await waitForPromises(); + + expect(axiosMock.history.post).toHaveLength(1); + expect(axiosMock.history.post[0]).toMatchObject({ + url: BROADCAST_MESSAGES_PATH, + data: JSON.stringify(defaultPayload), + }); + }); + + it('shows an error alert if the create request fails', async () => { + createComponent({ broadcastMessage: { id: undefined } }); + axiosMock.onPost(BROADCAST_MESSAGES_PATH).replyOnce(httpStatus.BAD_REQUEST); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: wrapper.vm.$options.i18n.addError, + }), + ); + }); + + it('sends an update request for a persisted message form', async () => { + const id = 1337; + createComponent({ broadcastMessage: { id } }); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await waitForPromises(); + + expect(axiosMock.history.patch).toHaveLength(1); + expect(axiosMock.history.patch[0]).toMatchObject({ + url: `${BROADCAST_MESSAGES_PATH}/${id}`, + data: JSON.stringify(defaultPayload), + }); + }); + + it('shows an error alert if the update request fails', async () => { + const id = 1337; + createComponent({ broadcastMessage: { id } }); + axiosMock.onPost(`${BROADCAST_MESSAGES_PATH}/${id}`).replyOnce(httpStatus.BAD_REQUEST); + findForm().vm.$emit('submit', { preventDefault: () => {} }); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: wrapper.vm.$options.i18n.updateError, + }), + ); + }); + }); +}); diff --git a/spec/frontend/admin/broadcast_messages/mock_data.js b/spec/frontend/admin/broadcast_messages/mock_data.js index 8dd98c2319d..2e20b5cf638 100644 --- a/spec/frontend/admin/broadcast_messages/mock_data.js +++ b/spec/frontend/admin/broadcast_messages/mock_data.js @@ -15,3 +15,11 @@ export const generateMockMessages = (n) => [...Array(n).keys()].map((id) => generateMockMessage(id + 1)); export const MOCK_MESSAGES = generateMockMessages(5).map((id) => generateMockMessage(id)); + +export const MOCK_TARGET_ACCESS_LEVELS = [ + ['Guest', 10], + ['Reporter', 20], + ['Developer', 30], + ['Maintainer', 40], + ['Owner', 50], +]; diff --git a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js index e6718f62b91..f2a951bcc76 100644 --- a/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js +++ b/spec/frontend/admin/signup_restrictions/components/signup_form_spec.js @@ -54,7 +54,6 @@ describe('Signup Form', () => { prop | propValue | elementSelector | formElementPassedDataType | formElementKey | expected ${'signupEnabled'} | ${mockData.signupEnabled} | ${'[name="application_setting[signup_enabled]"]'} | ${'prop'} | ${'value'} | ${mockData.signupEnabled} ${'requireAdminApprovalAfterUserSignup'} | ${mockData.requireAdminApprovalAfterUserSignup} | ${'[name="application_setting[require_admin_approval_after_user_signup]"]'} | ${'prop'} | ${'value'} | ${mockData.requireAdminApprovalAfterUserSignup} - ${'sendUserConfirmationEmail'} | ${mockData.sendUserConfirmationEmail} | ${'[name="application_setting[send_user_confirmation_email]"]'} | ${'prop'} | ${'value'} | ${mockData.sendUserConfirmationEmail} ${'newUserSignupsCap'} | ${mockData.newUserSignupsCap} | ${'[name="application_setting[new_user_signups_cap]"]'} | ${'attribute'} | ${'value'} | ${mockData.newUserSignupsCap} ${'minimumPasswordLength'} | ${mockData.minimumPasswordLength} | ${'[name="application_setting[minimum_password_length]"]'} | ${'attribute'} | ${'value'} | ${mockData.minimumPasswordLength} ${'minimumPasswordLengthMin'} | ${mockData.minimumPasswordLengthMin} | ${'[name="application_setting[minimum_password_length]"]'} | ${'attribute'} | ${'min'} | ${mockData.minimumPasswordLengthMin} diff --git a/spec/frontend/admin/signup_restrictions/mock_data.js b/spec/frontend/admin/signup_restrictions/mock_data.js index dd1ed317497..ce5ec2248fe 100644 --- a/spec/frontend/admin/signup_restrictions/mock_data.js +++ b/spec/frontend/admin/signup_restrictions/mock_data.js @@ -3,7 +3,6 @@ export const rawMockData = { settingsPath: 'path/to/settings', signupEnabled: 'true', requireAdminApprovalAfterUserSignup: 'true', - sendUserConfirmationEmail: 'true', emailConfirmationSetting: 'hard', minimumPasswordLength: '8', minimumPasswordLengthMin: '3', @@ -32,7 +31,6 @@ export const mockData = { settingsPath: 'path/to/settings', signupEnabled: true, requireAdminApprovalAfterUserSignup: true, - sendUserConfirmationEmail: true, emailConfirmationSetting: 'hard', minimumPasswordLength: '8', minimumPasswordLengthMin: '3', diff --git a/spec/frontend/admin/signup_restrictions/utils_spec.js b/spec/frontend/admin/signup_restrictions/utils_spec.js index f07e14430f9..e393b07baa9 100644 --- a/spec/frontend/admin/signup_restrictions/utils_spec.js +++ b/spec/frontend/admin/signup_restrictions/utils_spec.js @@ -10,7 +10,6 @@ describe('utils', () => { booleanAttributes: [ 'signupEnabled', 'requireAdminApprovalAfterUserSignup', - 'sendUserConfirmationEmail', 'domainDenylistEnabled', 'denylistTypeRawSelected', 'emailRestrictionsEnabled', diff --git a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap index 4693d5a47e4..bff4905a12c 100644 --- a/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap +++ b/spec/frontend/alerts_settings/components/__snapshots__/alerts_form_spec.js.snap @@ -16,7 +16,7 @@ exports[`Alert integration settings form default state should match the default > <gl-form-checkbox-stub checked="true" - data-qa-selector="create_issue_checkbox" + data-qa-selector="create_incident_checkbox" id="2" > <span> diff --git a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js index fcefcb7cf66..62a3e07186a 100644 --- a/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js +++ b/spec/frontend/alerts_settings/components/alerts_settings_wrapper_spec.js @@ -32,7 +32,7 @@ import { } from '~/alerts_settings/utils/error_messages'; import { createAlert, VARIANT_SUCCESS } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import httpStatusCodes, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { createHttpVariables, updateHttpVariables, @@ -358,7 +358,7 @@ describe('AlertsSettingsWrapper', () => { }); it('shows an error alert when integration test payload is invalid', async () => { - mock.onPost(/(.*)/).replyOnce(httpStatusCodes.UNPROCESSABLE_ENTITY); + mock.onPost(/(.*)/).replyOnce(HTTP_STATUS_UNPROCESSABLE_ENTITY); await wrapper.vm.testAlertPayload({ endpoint: '', data: '', token: '' }); expect(createAlert).toHaveBeenCalledWith({ message: INTEGRATION_PAYLOAD_TEST_ERROR }); expect(createAlert).toHaveBeenCalledTimes(1); diff --git a/spec/frontend/cycle_analytics/__snapshots__/total_time_spec.js.snap b/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap index 92927ef16ec..92927ef16ec 100644 --- a/spec/frontend/cycle_analytics/__snapshots__/total_time_spec.js.snap +++ b/spec/frontend/analytics/cycle_analytics/__snapshots__/total_time_spec.js.snap diff --git a/spec/frontend/cycle_analytics/base_spec.js b/spec/frontend/analytics/cycle_analytics/base_spec.js index 013bea671a8..58588ff49ce 100644 --- a/spec/frontend/cycle_analytics/base_spec.js +++ b/spec/frontend/analytics/cycle_analytics/base_spec.js @@ -4,12 +4,12 @@ import Vue from 'vue'; import Vuex from 'vuex'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import ValueStreamMetrics from '~/analytics/shared/components/value_stream_metrics.vue'; -import BaseComponent from '~/cycle_analytics/components/base.vue'; -import PathNavigation from '~/cycle_analytics/components/path_navigation.vue'; -import StageTable from '~/cycle_analytics/components/stage_table.vue'; -import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; -import { NOT_ENOUGH_DATA_ERROR } from '~/cycle_analytics/constants'; -import initState from '~/cycle_analytics/store/state'; +import BaseComponent from '~/analytics/cycle_analytics/components/base.vue'; +import PathNavigation from '~/analytics/cycle_analytics/components/path_navigation.vue'; +import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue'; +import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue'; +import { NOT_ENOUGH_DATA_ERROR } from '~/analytics/cycle_analytics/constants'; +import initState from '~/analytics/cycle_analytics/store/state'; import { transformedProjectStagePathData, selectedStage, diff --git a/spec/frontend/cycle_analytics/filter_bar_spec.js b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js index 36933790cf7..2b26b202882 100644 --- a/spec/frontend/cycle_analytics/filter_bar_spec.js +++ b/spec/frontend/analytics/cycle_analytics/filter_bar_spec.js @@ -7,10 +7,16 @@ import { filterMilestones, filterLabels, } from 'jest/vue_shared/components/filtered_search_bar/store/modules/filters/mock_data'; -import FilterBar from '~/cycle_analytics/components/filter_bar.vue'; -import storeConfig from '~/cycle_analytics/store'; +import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue'; +import storeConfig from '~/analytics/cycle_analytics/store'; import * as commonUtils from '~/lib/utils/common_utils'; import * as urlUtils from '~/lib/utils/url_utility'; +import { + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import * as utils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import initialFiltersState from '~/vue_shared/components/filtered_search_bar/store/modules/filters/state'; @@ -18,10 +24,10 @@ import UrlSync from '~/vue_shared/components/url_sync.vue'; Vue.use(Vuex); -const milestoneTokenType = 'milestone'; -const labelsTokenType = 'labels'; -const authorTokenType = 'author'; -const assigneesTokenType = 'assignees'; +const milestoneTokenType = TOKEN_TYPE_MILESTONE; +const labelsTokenType = TOKEN_TYPE_LABEL; +const authorTokenType = TOKEN_TYPE_AUTHOR; +const assigneesTokenType = TOKEN_TYPE_ASSIGNEE; const initialFilterBarState = { selectedMilestone: null, @@ -162,8 +168,8 @@ describe('Filter bar', () => { it('clicks on the search button, setFilters is dispatched', () => { const filters = [ - { type: 'milestone', value: { data: selectedMilestone[0].title, operator: '=' } }, - { type: 'labels', value: { data: selectedLabelList[0].title, operator: '=' } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: selectedMilestone[0].title, operator: '=' } }, + { type: TOKEN_TYPE_LABEL, value: { data: selectedLabelList[0].title, operator: '=' } }, ]; findFilteredSearch().vm.$emit('onFilter', filters); diff --git a/spec/frontend/cycle_analytics/formatted_stage_count_spec.js b/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js index 1228b8511ea..9be92bb92bc 100644 --- a/spec/frontend/cycle_analytics/formatted_stage_count_spec.js +++ b/spec/frontend/analytics/cycle_analytics/formatted_stage_count_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import Component from '~/cycle_analytics/components/formatted_stage_count.vue'; +import Component from '~/analytics/cycle_analytics/components/formatted_stage_count.vue'; describe('Formatted Stage Count', () => { let wrapper = null; diff --git a/spec/frontend/cycle_analytics/mock_data.js b/spec/frontend/analytics/cycle_analytics/mock_data.js index 02666260cdb..f820f755400 100644 --- a/spec/frontend/cycle_analytics/mock_data.js +++ b/spec/frontend/analytics/cycle_analytics/mock_data.js @@ -12,7 +12,7 @@ import { PAGINATION_TYPE, PAGINATION_SORT_DIRECTION_DESC, PAGINATION_SORT_FIELD_END_EVENT, -} from '~/cycle_analytics/constants'; +} from '~/analytics/cycle_analytics/constants'; import { convertObjectPropsToCamelCase } from '~/lib/utils/common_utils'; import { getDateInPast } from '~/lib/utils/datetime_utility'; diff --git a/spec/frontend/cycle_analytics/path_navigation_spec.js b/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js index fec1526359c..107e62035c3 100644 --- a/spec/frontend/cycle_analytics/path_navigation_spec.js +++ b/spec/frontend/analytics/cycle_analytics/path_navigation_spec.js @@ -2,7 +2,7 @@ import { GlPath, GlSkeletonLoader } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import Component from '~/cycle_analytics/components/path_navigation.vue'; +import Component from '~/analytics/cycle_analytics/components/path_navigation.vue'; import { transformedProjectStagePathData, selectedStage } from './mock_data'; describe('Project PathNavigation', () => { diff --git a/spec/frontend/cycle_analytics/stage_table_spec.js b/spec/frontend/analytics/cycle_analytics/stage_table_spec.js index 473e1d5b664..cfccce7eae9 100644 --- a/spec/frontend/cycle_analytics/stage_table_spec.js +++ b/spec/frontend/analytics/cycle_analytics/stage_table_spec.js @@ -3,8 +3,8 @@ import { shallowMount, mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import StageTable from '~/cycle_analytics/components/stage_table.vue'; -import { PAGINATION_SORT_FIELD_DURATION } from '~/cycle_analytics/constants'; +import StageTable from '~/analytics/cycle_analytics/components/stage_table.vue'; +import { PAGINATION_SORT_FIELD_DURATION } from '~/analytics/cycle_analytics/constants'; import { issueEvents, issueStage, reviewStage, reviewEvents } from './mock_data'; let wrapper = null; diff --git a/spec/frontend/cycle_analytics/store/actions_spec.js b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js index 94b6de85a5c..f87807804c9 100644 --- a/spec/frontend/cycle_analytics/store/actions_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/actions_spec.js @@ -1,8 +1,8 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import * as actions from '~/cycle_analytics/store/actions'; -import * as getters from '~/cycle_analytics/store/getters'; +import * as actions from '~/analytics/cycle_analytics/store/actions'; +import * as getters from '~/analytics/cycle_analytics/store/getters'; import httpStatusCodes from '~/lib/utils/http_status'; import { allowedStages, diff --git a/spec/frontend/cycle_analytics/store/getters_spec.js b/spec/frontend/analytics/cycle_analytics/store/getters_spec.js index c9208045a68..8ad1e1b27de 100644 --- a/spec/frontend/cycle_analytics/store/getters_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/getters_spec.js @@ -1,4 +1,4 @@ -import * as getters from '~/cycle_analytics/store/getters'; +import * as getters from '~/analytics/cycle_analytics/store/getters'; import { allowedStages, diff --git a/spec/frontend/cycle_analytics/store/mutations_spec.js b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js index 2e9e5d91471..567fac81e1f 100644 --- a/spec/frontend/cycle_analytics/store/mutations_spec.js +++ b/spec/frontend/analytics/cycle_analytics/store/mutations_spec.js @@ -1,10 +1,10 @@ import { useFakeDate } from 'helpers/fake_date'; -import * as types from '~/cycle_analytics/store/mutation_types'; -import mutations from '~/cycle_analytics/store/mutations'; +import * as types from '~/analytics/cycle_analytics/store/mutation_types'; +import mutations from '~/analytics/cycle_analytics/store/mutations'; import { PAGINATION_SORT_FIELD_END_EVENT, PAGINATION_SORT_DIRECTION_DESC, -} from '~/cycle_analytics/constants'; +} from '~/analytics/cycle_analytics/constants'; import { selectedStage, rawIssueEvents, diff --git a/spec/frontend/cycle_analytics/total_time_spec.js b/spec/frontend/analytics/cycle_analytics/total_time_spec.js index 8cf9feab6e9..47ee7aad8c4 100644 --- a/spec/frontend/cycle_analytics/total_time_spec.js +++ b/spec/frontend/analytics/cycle_analytics/total_time_spec.js @@ -1,5 +1,5 @@ import { mount } from '@vue/test-utils'; -import TotalTime from '~/cycle_analytics/components/total_time.vue'; +import TotalTime from '~/analytics/cycle_analytics/components/total_time.vue'; describe('TotalTime', () => { let wrapper = null; diff --git a/spec/frontend/cycle_analytics/utils_spec.js b/spec/frontend/analytics/cycle_analytics/utils_spec.js index 51405a1ba4d..fe412bf7498 100644 --- a/spec/frontend/cycle_analytics/utils_spec.js +++ b/spec/frontend/analytics/cycle_analytics/utils_spec.js @@ -4,7 +4,7 @@ import { formatMedianValues, filterStagesByHiddenStatus, buildCycleAnalyticsInitialData, -} from '~/cycle_analytics/utils'; +} from '~/analytics/cycle_analytics/utils'; import { selectedStage, allowedStages, diff --git a/spec/frontend/cycle_analytics/value_stream_filters_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js index 6e96a6d756a..4f333e95d89 100644 --- a/spec/frontend/cycle_analytics/value_stream_filters_spec.js +++ b/spec/frontend/analytics/cycle_analytics/value_stream_filters_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import Daterange from '~/analytics/shared/components/daterange.vue'; import ProjectsDropdownFilter from '~/analytics/shared/components/projects_dropdown_filter.vue'; -import FilterBar from '~/cycle_analytics/components/filter_bar.vue'; -import ValueStreamFilters from '~/cycle_analytics/components/value_stream_filters.vue'; +import FilterBar from '~/analytics/cycle_analytics/components/filter_bar.vue'; +import ValueStreamFilters from '~/analytics/cycle_analytics/components/value_stream_filters.vue'; import { createdAfter as startDate, createdBefore as endDate, diff --git a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js b/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js index 948dc5c9be2..948dc5c9be2 100644 --- a/spec/frontend/cycle_analytics/value_stream_metrics_spec.js +++ b/spec/frontend/analytics/cycle_analytics/value_stream_metrics_spec.js diff --git a/spec/frontend/api_spec.js b/spec/frontend/api_spec.js index 1f92010b771..5209d9c2d2c 100644 --- a/spec/frontend/api_spec.js +++ b/spec/frontend/api_spec.js @@ -1,7 +1,11 @@ import MockAdapter from 'axios-mock-adapter'; import Api, { DEFAULT_PER_PAGE } from '~/api'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { + HTTP_STATUS_ACCEPTED, + HTTP_STATUS_CREATED, + HTTP_STATUS_NO_CONTENT, +} from '~/lib/utils/http_status'; jest.mock('~/flash'); @@ -1069,7 +1073,7 @@ describe('Api', () => { describe('when the release is successfully created', () => { it('resolves the Promise', () => { - mock.onPost(expectedUrl, release).replyOnce(httpStatus.CREATED); + mock.onPost(expectedUrl, release).replyOnce(HTTP_STATUS_CREATED); return Api.createRelease(dummyProjectPath, release).then(() => { expect(mock.history.post).toHaveLength(1); @@ -1125,7 +1129,7 @@ describe('Api', () => { describe('when the Release is successfully created', () => { it('resolves the Promise', () => { - mock.onPost(expectedUrl, expectedLink).replyOnce(httpStatus.CREATED); + mock.onPost(expectedUrl, expectedLink).replyOnce(HTTP_STATUS_CREATED); return Api.createReleaseLink(dummyProjectPath, dummyTagName, expectedLink).then(() => { expect(mock.history.post).toHaveLength(1); @@ -1224,7 +1228,7 @@ describe('Api', () => { describe('when the merge request is successfully created', () => { it('resolves the Promise', () => { - mock.onPost(expectedUrl, options).replyOnce(httpStatus.CREATED); + mock.onPost(expectedUrl, options).replyOnce(HTTP_STATUS_CREATED); return Api.createProjectMergeRequest(dummyProjectPath, options).then(() => { expect(mock.history.post).toHaveLength(1); @@ -1332,7 +1336,7 @@ describe('Api', () => { describe('when the freeze period is successfully created', () => { it('resolves the Promise', () => { - mock.onPost(expectedUrl, options).replyOnce(httpStatus.CREATED, expectedResult); + mock.onPost(expectedUrl, options).replyOnce(HTTP_STATUS_CREATED, expectedResult); return Api.createFreezePeriod(projectId, options).then(({ data }) => { expect(data).toStrictEqual(expectedResult); @@ -1598,7 +1602,7 @@ describe('Api', () => { const secureFileId = 2; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/projects/${projectId}/secure_files/${secureFileId}`; - mock.onDelete(expectedUrl).reply(httpStatus.NO_CONTENT, ''); + mock.onDelete(expectedUrl).reply(HTTP_STATUS_NO_CONTENT, ''); const { data } = await Api.deleteProjectSecureFile(projectId, secureFileId); expect(data).toEqual(''); }); @@ -1609,10 +1613,10 @@ describe('Api', () => { const groupId = 1; const expectedUrl = `${dummyUrlRoot}/api/${dummyApiVersion}/groups/${groupId}/dependency_proxy/cache`; - mock.onDelete(expectedUrl).reply(httpStatus.ACCEPTED); + mock.onDelete(expectedUrl).reply(HTTP_STATUS_ACCEPTED); const { status } = await Api.deleteDependencyProxyCacheList(groupId, {}); - expect(status).toBe(httpStatus.ACCEPTED); + expect(status).toBe(HTTP_STATUS_ACCEPTED); }); }); diff --git a/spec/frontend/batch_comments/components/draft_note_spec.js b/spec/frontend/batch_comments/components/draft_note_spec.js index 03ecbc01a56..2dfcdd551a1 100644 --- a/spec/frontend/batch_comments/components/draft_note_spec.js +++ b/spec/frontend/batch_comments/components/draft_note_spec.js @@ -1,19 +1,20 @@ import { nextTick } from 'vue'; import { GlButton, GlBadge } from '@gitlab/ui'; -import { getByRole } from '@testing-library/dom'; import { shallowMount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import PublishButton from '~/batch_comments/components/publish_button.vue'; import { createStore } from '~/batch_comments/stores'; import NoteableNote from '~/notes/components/noteable_note.vue'; -import '~/behaviors/markdown/render_gfm'; import { createDraft } from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + const NoteableNoteStub = stubComponent(NoteableNote, { template: ` <div> <slot name="note-header-info">Test</slot> + <slot name="after-note-body">Test</slot> </div> `, }); @@ -29,7 +30,6 @@ describe('Batch comments draft note component', () => { }, }; - const getList = () => getByRole(wrapper.element, 'list'); const findSubmitReviewButton = () => wrapper.findComponent(PublishButton); const findAddCommentButton = () => wrapper.findComponent(GlButton); @@ -189,7 +189,7 @@ describe('Batch comments draft note component', () => { }); it(`calls store ${expectedCalls.length} times on ${event}`, () => { - getList().dispatchEvent(new MouseEvent(event, { bubbles: true })); + wrapper.element.dispatchEvent(new MouseEvent(event, { bubbles: true })); expect(store.dispatch.mock.calls).toEqual(expectedCalls); }); }); diff --git a/spec/frontend/batch_comments/components/preview_item_spec.js b/spec/frontend/batch_comments/components/preview_item_spec.js index 6a104f0c787..6a99294f855 100644 --- a/spec/frontend/batch_comments/components/preview_item_spec.js +++ b/spec/frontend/batch_comments/components/preview_item_spec.js @@ -3,9 +3,10 @@ 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'; -import '~/behaviors/markdown/render_gfm'; import { createDraft } from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + describe('Batch comments draft preview item component', () => { let wrapper; let draft; diff --git a/spec/frontend/batch_comments/components/publish_dropdown_spec.js b/spec/frontend/batch_comments/components/publish_dropdown_spec.js index d1b7160d231..e89934c0192 100644 --- a/spec/frontend/batch_comments/components/publish_dropdown_spec.js +++ b/spec/frontend/batch_comments/components/publish_dropdown_spec.js @@ -4,9 +4,10 @@ import Vue from 'vue'; import Vuex from 'vuex'; 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'; +jest.mock('~/behaviors/markdown/render_gfm'); + Vue.use(Vuex); describe('Batch comments publish dropdown component', () => { diff --git a/spec/frontend/behaviors/markdown/render_observability_spec.js b/spec/frontend/behaviors/markdown/render_observability_spec.js new file mode 100644 index 00000000000..c87d11742dc --- /dev/null +++ b/spec/frontend/behaviors/markdown/render_observability_spec.js @@ -0,0 +1,38 @@ +import renderObservability from '~/behaviors/markdown/render_observability'; +import * as ColorUtils from '~/lib/utils/color_utils'; + +describe('Observability iframe renderer', () => { + const findObservabilityIframes = (theme = 'light') => + document.querySelectorAll(`iframe[src="https://observe.gitlab.com/?theme=${theme}&kiosk"]`); + + const renderEmbeddedObservability = () => { + renderObservability([...document.querySelectorAll('.js-render-observability')]); + jest.runAllTimers(); + }; + + beforeEach(() => { + document.body.dataset.page = ''; + document.body.innerHTML = ''; + }); + + it('renders an observability iframe', () => { + document.body.innerHTML = `<div class="js-render-observability" data-frame-url="https://observe.gitlab.com/"></div>`; + + expect(findObservabilityIframes()).toHaveLength(0); + + renderEmbeddedObservability(); + + expect(findObservabilityIframes()).toHaveLength(1); + }); + + it('renders iframe with dark param when GL has dark theme', () => { + document.body.innerHTML = `<div class="js-render-observability" data-frame-url="https://observe.gitlab.com/"></div>`; + jest.spyOn(ColorUtils, 'darkModeEnabled').mockImplementation(() => true); + + expect(findObservabilityIframes('dark')).toHaveLength(0); + + renderEmbeddedObservability(); + + expect(findObservabilityIframes('dark')).toHaveLength(1); + }); +}); diff --git a/spec/frontend/blob/openapi/index_spec.js b/spec/frontend/blob/openapi/index_spec.js index 17e718df495..d9d65258516 100644 --- a/spec/frontend/blob/openapi/index_spec.js +++ b/spec/frontend/blob/openapi/index_spec.js @@ -21,7 +21,7 @@ describe('OpenAPI blob viewer', () => { it('initializes SwaggerUI with the correct configuration', () => { expect(document.body.innerHTML).toContain( - '<iframe src="/-/sandbox/swagger" sandbox="allow-scripts allow-popups" frameborder="0" width="100%" height="1000"></iframe>', + '<iframe src="/-/sandbox/swagger" sandbox="allow-scripts allow-popups allow-forms" frameborder="0" width="100%" height="1000"></iframe>', ); }); }); diff --git a/spec/frontend/blob_edit/blob_bundle_spec.js b/spec/frontend/blob_edit/blob_bundle_spec.js index 644539308c2..ed42322b0e6 100644 --- a/spec/frontend/blob_edit/blob_bundle_spec.js +++ b/spec/frontend/blob_edit/blob_bundle_spec.js @@ -5,8 +5,10 @@ import waitForPromises from 'helpers/wait_for_promises'; import blobBundle from '~/blob_edit/blob_bundle'; import SourceEditor from '~/blob_edit/edit_blob'; +import { createAlert } from '~/flash'; jest.mock('~/blob_edit/edit_blob'); +jest.mock('~/flash'); describe('BlobBundle', () => { it('does not load SourceEditor by default', () => { @@ -93,4 +95,26 @@ describe('BlobBundle', () => { }); }); }); + + describe('Error handling', () => { + let message; + beforeEach(() => { + setHTMLFixture(`<div class="js-edit-blob-form" data-blob-filename="blah"></div>`); + message = 'Foo'; + SourceEditor.mockImplementation(() => { + throw new Error(message); + }); + }); + + afterEach(() => { + resetHTMLFixture(); + SourceEditor.mockClear(); + }); + + it('correctly outputs error message when it occurs', async () => { + blobBundle(); + await waitForPromises(); + expect(createAlert).toHaveBeenCalledWith({ message }); + }); + }); }); diff --git a/spec/frontend/boards/board_list_spec.js b/spec/frontend/boards/board_list_spec.js index 3a2beb714e9..34c0504143c 100644 --- a/spec/frontend/boards/board_list_spec.js +++ b/spec/frontend/boards/board_list_spec.js @@ -198,6 +198,13 @@ describe('Board list component', () => { expect(findDraggable().exists()).toBe(true); }); + it('sets delay and delayOnTouchOnly attributes on board list', () => { + const listEl = wrapper.findComponent({ ref: 'list' }); + + expect(listEl.attributes('delay')).toBe('100'); + expect(listEl.attributes('delayontouchonly')).toBe('true'); + }); + describe('handleDragOnStart', () => { it('adds a class `is-dragging` to document body', () => { expect(document.body.classList.contains('is-dragging')).toBe(false); @@ -269,6 +276,10 @@ describe('Board list component', () => { it('Draggable is not used', () => { expect(findDraggable().exists()).toBe(false); }); + + it('Board card move to position is not visible', () => { + expect(findMoveToPositionComponent().exists()).toBe(false); + }); }); }); }); diff --git a/spec/frontend/boards/components/board_content_sidebar_spec.js b/spec/frontend/boards/components/board_content_sidebar_spec.js index 7e35c39cd48..0d5b1d16e30 100644 --- a/spec/frontend/boards/components/board_content_sidebar_spec.js +++ b/spec/frontend/boards/components/board_content_sidebar_spec.js @@ -12,7 +12,7 @@ import SidebarDateWidget from '~/sidebar/components/date/sidebar_date_widget.vue import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; import SidebarSubscriptionsWidget from '~/sidebar/components/subscriptions/sidebar_subscriptions_widget.vue'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; -import SidebarLabelsWidget from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import SidebarLabelsWidget from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; import { mockActiveIssue, mockIssue, mockIssueGroupPath, mockIssueProjectPath } from '../mock_data'; Vue.use(Vuex); @@ -146,6 +146,20 @@ describe('BoardContentSidebar', () => { expect(wrapper.findComponent(SidebarSeverity).exists()).toBe(false); }); + it('does not render SidebarHealthStatusWidget', async () => { + const SidebarHealthStatusWidget = ( + await import('ee_component/sidebar/components/health_status/sidebar_health_status_widget.vue') + ).default; + expect(wrapper.findComponent(SidebarHealthStatusWidget).exists()).toBe(false); + }); + + it('does not render SidebarWeightWidget', async () => { + const SidebarWeightWidget = ( + await import('ee_component/sidebar/components/weight/sidebar_weight_widget.vue') + ).default; + expect(wrapper.findComponent(SidebarWeightWidget).exists()).toBe(false); + }); + describe('when we emit close', () => { let toggleBoardItem; diff --git a/spec/frontend/boards/components/board_content_spec.js b/spec/frontend/boards/components/board_content_spec.js index b2138700602..82e7ab48e7d 100644 --- a/spec/frontend/boards/components/board_content_spec.js +++ b/spec/frontend/boards/components/board_content_spec.js @@ -123,15 +123,39 @@ describe('BoardContent', () => { expect(wrapper.findComponent(GlAlert).exists()).toBe(false); }); - it('resizes the list on resize', async () => { + it('on small screens, sets board container height to full height', async () => { window.innerHeight = 1000; + window.innerWidth = 767; jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ top: 100 }); wrapper.vm.resizeObserver.trigger(); await nextTick(); - expect(wrapper.findComponent({ ref: 'list' }).attributes('style')).toBe('height: 900px;'); + const style = wrapper.findComponent({ ref: 'list' }).attributes('style'); + + expect(style).toBe('height: 1000px;'); + }); + + it('on large screens, sets board container height fill area below filters', async () => { + window.innerHeight = 1000; + window.innerWidth = 768; + jest.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue({ top: 100 }); + + wrapper.vm.resizeObserver.trigger(); + + await nextTick(); + + const style = wrapper.findComponent({ ref: 'list' }).attributes('style'); + + expect(style).toBe('height: 900px;'); + }); + + it('sets delay and delayOnTouchOnly attributes on board list', () => { + const listEl = wrapper.findComponent({ ref: 'list' }); + + expect(listEl.attributes('delay')).toBe('100'); + expect(listEl.attributes('delayontouchonly')).toBe('true'); }); }); diff --git a/spec/frontend/boards/components/board_filtered_search_spec.js b/spec/frontend/boards/components/board_filtered_search_spec.js index 6f17e4193a3..e80c66f7fb8 100644 --- a/spec/frontend/boards/components/board_filtered_search_spec.js +++ b/spec/frontend/boards/components/board_filtered_search_spec.js @@ -17,7 +17,7 @@ import { TOKEN_TYPE_WEIGHT, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import { createStore } from '~/boards/stores'; @@ -30,7 +30,7 @@ describe('BoardFilteredSearch', () => { { icon: 'labels', title: TOKEN_TITLE_LABEL, - type: 'label', + type: TOKEN_TYPE_LABEL, operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, @@ -43,15 +43,15 @@ describe('BoardFilteredSearch', () => { { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, - type: 'author', + type: TOKEN_TYPE_AUTHOR, operators: [ { value: '=', description: 'is' }, { value: '!=', description: 'is not' }, ], symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors: () => new Promise(() => {}), + fetchUsers: () => new Promise(() => {}), }, ]; @@ -109,7 +109,7 @@ describe('BoardFilteredSearch', () => { createComponent({ props: { eeFilters: { labelName: ['label'] } } }); expect(findFilteredSearch().props('initialFilterValue')).toEqual([ - { type: 'label', value: { data: 'label', operator: '=' } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'label', operator: '=' } }, ]); }); }); @@ -158,7 +158,9 @@ describe('BoardFilteredSearch', () => { ['None', url('None')], ['Any', url('Any')], ])('sets the url param %s', (assigneeParam, expected) => { - const mockFilters = [{ type: 'assignee', value: { data: assigneeParam, operator: '=' } }]; + const mockFilters = [ + { type: TOKEN_TYPE_ASSIGNEE, value: { data: assigneeParam, operator: '=' } }, + ]; jest.spyOn(urlUtility, 'updateHistory'); findFilteredSearch().vm.$emit('onFilter', mockFilters); diff --git a/spec/frontend/boards/components/issue_board_filtered_search_spec.js b/spec/frontend/boards/components/issue_board_filtered_search_spec.js index e4a6a2b8b76..513561307cd 100644 --- a/spec/frontend/boards/components/issue_board_filtered_search_spec.js +++ b/spec/frontend/boards/components/issue_board_filtered_search_spec.js @@ -23,14 +23,14 @@ describe('IssueBoardFilter', () => { }); }; - let fetchAuthorsSpy; + let fetchUsersSpy; let fetchLabelsSpy; beforeEach(() => { - fetchAuthorsSpy = jest.fn(); + fetchUsersSpy = jest.fn(); fetchLabelsSpy = jest.fn(); issueBoardFilters.mockReturnValue({ - fetchAuthors: fetchAuthorsSpy, + fetchUsers: fetchUsersSpy, fetchLabels: fetchLabelsSpy, }); }); @@ -59,7 +59,7 @@ describe('IssueBoardFilter', () => { const tokens = mockTokens( fetchLabelsSpy, - fetchAuthorsSpy, + fetchUsersSpy, wrapper.vm.fetchMilestones, isSignedIn, ); diff --git a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js index 5c435643425..e2e4baefad0 100644 --- a/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js +++ b/spec/frontend/boards/components/sidebar/board_sidebar_time_tracker_spec.js @@ -42,13 +42,20 @@ describe('BoardSidebarTimeTracker', () => { wrapper = null; }); - it.each([[true], [false]])( - 'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=%s)', - (timeTrackingLimitToHours) => { - createComponent({ provide: { timeTrackingLimitToHours } }); + it.each` + timeTrackingLimitToHours | canUpdate + ${true} | ${false} + ${true} | ${true} + ${false} | ${false} + ${false} | ${true} + `( + 'renders IssuableTimeTracker with correct spent and estimated time (timeTrackingLimitToHours=$timeTrackingLimitToHours, canUpdate=$canUpdate)', + ({ timeTrackingLimitToHours, canUpdate }) => { + createComponent({ provide: { timeTrackingLimitToHours, canUpdate } }); expect(wrapper.findComponent(IssuableTimeTracker).props()).toEqual({ limitToHours: timeTrackingLimitToHours, + canAddTimeEntries: canUpdate, showCollapsed: false, issuableId: '1', issuableIid: '1', diff --git a/spec/frontend/boards/mock_data.js b/spec/frontend/boards/mock_data.js index 3c26fa97338..df41eb05eae 100644 --- a/spec/frontend/boards/mock_data.js +++ b/spec/frontend/boards/mock_data.js @@ -2,22 +2,26 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; import { keyBy } from 'lodash'; import { ListType } from '~/boards/constants'; import { - OPERATOR_IS_AND_IS_NOT, - OPERATOR_IS_ONLY, + OPERATORS_IS, + OPERATORS_IS_NOT, TOKEN_TITLE_ASSIGNEE, TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONFIDENTIAL, TOKEN_TITLE_LABEL, TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, TOKEN_TITLE_RELEASE, TOKEN_TITLE_TYPE, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, TOKEN_TYPE_LABEL, TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_RELEASE, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_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'; @@ -733,54 +737,54 @@ export const mockMoveData = { }; export const mockEmojiToken = { - type: 'my-reaction', + type: TOKEN_TYPE_MY_REACTION, icon: 'thumb-up', - title: 'My-Reaction', + title: TOKEN_TITLE_MY_REACTION, unique: true, token: EmojiToken, fetchEmojis: expect.any(Function), }; export const mockConfidentialToken = { - type: 'confidential', + type: TOKEN_TYPE_CONFIDENTIAL, icon: 'eye-slash', - title: 'Confidential', + title: TOKEN_TITLE_CONFIDENTIAL, unique: true, token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { icon: 'eye-slash', value: 'yes', title: 'Yes' }, { icon: 'eye', value: 'no', title: 'No' }, ], }; -export const mockTokens = (fetchLabels, fetchAuthors, fetchMilestones, isSignedIn) => [ +export const mockTokens = (fetchLabels, fetchUsers, fetchMilestones, isSignedIn) => [ { icon: 'user', title: TOKEN_TITLE_ASSIGNEE, type: TOKEN_TYPE_ASSIGNEE, - operators: OPERATOR_IS_AND_IS_NOT, - token: AuthorToken, + operators: OPERATORS_IS_NOT, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: [], + fetchUsers, + preloadedUsers: [], }, { icon: 'pencil', title: TOKEN_TITLE_AUTHOR, type: TOKEN_TYPE_AUTHOR, - operators: OPERATOR_IS_AND_IS_NOT, + operators: OPERATORS_IS_NOT, symbol: '@', - token: AuthorToken, + token: UserToken, unique: true, - fetchAuthors, - preloadedAuthors: [], + fetchUsers, + preloadedUsers: [], }, { icon: 'labels', title: TOKEN_TITLE_LABEL, type: TOKEN_TYPE_LABEL, - operators: OPERATOR_IS_AND_IS_NOT, + operators: OPERATORS_IS_NOT, token: LabelToken, unique: false, symbol: '~', diff --git a/spec/frontend/boards/project_select_spec.js b/spec/frontend/boards/project_select_spec.js index 7ff34ffdf9e..4324e7068e0 100644 --- a/spec/frontend/boards/project_select_spec.js +++ b/spec/frontend/boards/project_select_spec.js @@ -156,7 +156,7 @@ describe('ProjectSelect component', () => { }); it('renders the name of the selected project', () => { - expect(findGlDropdown().find('.gl-new-dropdown-button-text').text()).toBe( + expect(findGlDropdown().find('.gl-dropdown-button-text').text()).toBe( mockProjectsList1[0].name, ); }); diff --git a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js index 553ca52f9ce..b2a25bc93ea 100644 --- a/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js +++ b/spec/frontend/captcha/captcha_modal_axios_interceptor_spec.js @@ -4,7 +4,10 @@ import { registerCaptchaModalInterceptor } from '~/captcha/captcha_modal_axios_i import UnsolvedCaptchaError from '~/captcha/unsolved_captcha_error'; import { waitForCaptchaToBeSolved } from '~/captcha/wait_for_captcha_to_be_solved'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import httpStatusCodes, { + HTTP_STATUS_CONFLICT, + HTTP_STATUS_METHOD_NOT_ALLOWED, +} from '~/lib/utils/http_status'; jest.mock('~/captcha/wait_for_captcha_to_be_solved'); @@ -33,7 +36,7 @@ describe('registerCaptchaModalInterceptor', () => { mock.onAny('/endpoint-with-unrelated-error').reply(404, AXIOS_RESPONSE); mock.onAny('/endpoint-with-captcha').reply((config) => { if (!supportedMethods.includes(config.method)) { - return [httpStatusCodes.METHOD_NOT_ALLOWED, { method: config.method }]; + return [HTTP_STATUS_METHOD_NOT_ALLOWED, { method: config.method }]; } const data = JSON.parse(config.data); @@ -46,7 +49,7 @@ describe('registerCaptchaModalInterceptor', () => { return [httpStatusCodes.OK, { ...data, method: config.method, CAPTCHA_SUCCESS }]; } - return [httpStatusCodes.CONFLICT, NEEDS_CAPTCHA_RESPONSE]; + return [HTTP_STATUS_CONFLICT, NEEDS_CAPTCHA_RESPONSE]; }); axios.interceptors.response.handlers = []; @@ -123,7 +126,7 @@ describe('registerCaptchaModalInterceptor', () => { await expect(() => axios[method]('/endpoint-with-captcha')).rejects.toThrow( expect.objectContaining({ response: expect.objectContaining({ - status: httpStatusCodes.METHOD_NOT_ALLOWED, + status: HTTP_STATUS_METHOD_NOT_ALLOWED, data: { method }, }), }), diff --git a/spec/frontend/ci_lint/components/ci_lint_spec.js b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js index ea69a80274e..d4f588a0e09 100644 --- a/spec/frontend/ci_lint/components/ci_lint_spec.js +++ b/spec/frontend/ci/ci_lint/components/ci_lint_spec.js @@ -2,9 +2,9 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; -import CiLint from '~/ci_lint/components/ci_lint.vue'; -import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import CiLint from '~/ci/ci_lint/components/ci_lint.vue'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; +import lintCIMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; import SourceEditor from '~/vue_shared/components/source_editor.vue'; import { mockLintDataValid } from '../mock_data'; diff --git a/spec/frontend/ci_lint/mock_data.js b/spec/frontend/ci/ci_lint/mock_data.js index 660b2ad6e8b..05582470dfa 100644 --- a/spec/frontend/ci_lint/mock_data.js +++ b/spec/frontend/ci/ci_lint/mock_data.js @@ -1,4 +1,4 @@ -import { mockJobs } from 'jest/pipeline_editor/mock_data'; +import { mockJobs } from 'jest/ci/pipeline_editor/mock_data'; export const mockLintDataError = { data: { diff --git a/spec/frontend/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js index d03f12bc249..b00e1adab63 100644 --- a/spec/frontend/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert_spec.js @@ -3,8 +3,8 @@ import { mount } from '@vue/test-utils'; import { merge } from 'lodash'; import { TEST_HOST } from 'helpers/test_constants'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; -import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/pipeline_editor/components/code_snippet_alert/constants'; +import CodeSnippetAlert from '~/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; +import { CODE_SNIPPET_SOURCE_API_FUZZING } from '~/ci/pipeline_editor/components/code_snippet_alert/constants'; const apiFuzzingConfigurationPath = '/namespace/project/-/security/configuration/api_fuzzing'; diff --git a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js index 0ee6da9d329..8e1d8081dd8 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_form_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_form_spec.js @@ -2,7 +2,7 @@ import { nextTick } from 'vue'; import { GlFormInput, GlFormTextarea } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; +import CommitForm from '~/ci/pipeline_editor/components/commit/commit_form.vue'; import { mockCommitMessage, mockDefaultBranch } from '../../mock_data'; diff --git a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js index 744b0378a75..f6e93c55bbb 100644 --- a/spec/frontend/pipeline_editor/components/commit/commit_section_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/commit/commit_section_spec.js @@ -4,18 +4,18 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import CommitForm from '~/pipeline_editor/components/commit/commit_form.vue'; -import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; +import CommitForm from '~/ci/pipeline_editor/components/commit/commit_form.vue'; +import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue'; import { COMMIT_ACTION_CREATE, COMMIT_ACTION_UPDATE, COMMIT_SUCCESS, COMMIT_SUCCESS_WITH_REDIRECT, -} from '~/pipeline_editor/constants'; -import { resolvers } from '~/pipeline_editor/graphql/resolvers'; -import commitCreate from '~/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql'; -import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; -import updatePipelineEtag from '~/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql'; +} from '~/ci/pipeline_editor/constants'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; +import commitCreate from '~/ci/pipeline_editor/graphql/mutations/commit_ci_file.mutation.graphql'; +import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import updatePipelineEtag from '~/ci/pipeline_editor/graphql/mutations/client/update_pipeline_etag.mutation.graphql'; import { mockCiConfigPath, diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js index 7e1e5004d91..137137ec657 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/first_pipeline_card_spec.js @@ -1,8 +1,8 @@ import { getByRole } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import FirstPipelineCard from '~/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; -import { pipelineEditorTrackingOptions } from '~/pipeline_editor/constants'; +import FirstPipelineCard from '~/ci/pipeline_editor/components/drawer/cards/first_pipeline_card.vue'; +import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants'; describe('First pipeline card', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js index c592e959068..cdce757ce7c 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/getting_started_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/getting_started_card_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import GettingStartedCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; +import GettingStartedCard from '~/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue'; describe('Getting started card', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js index 49177befe0e..6909916c3e6 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card_spec.js @@ -1,8 +1,8 @@ import { getByRole } from '@testing-library/dom'; import { mount } from '@vue/test-utils'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import PipelineConfigReferenceCard from '~/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; -import { pipelineEditorTrackingOptions } from '~/pipeline_editor/constants'; +import PipelineConfigReferenceCard from '~/ci/pipeline_editor/components/drawer/cards/pipeline_config_reference_card.vue'; +import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants'; describe('Pipeline config reference card', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js index bebd2484c1d..0c6879020de 100644 --- a/spec/frontend/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/cards/visualize_and_lint_card_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import VisualizeAndLintCard from '~/pipeline_editor/components/drawer/cards/getting_started_card.vue'; +import VisualizeAndLintCard from '~/ci/pipeline_editor/components/drawer/cards/getting_started_card.vue'; describe('Visual and Lint card', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js index 33b53bf6a56..42e372cc1db 100644 --- a/spec/frontend/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/pipeline_editor_drawer_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { GlDrawer } from '@gitlab/ui'; -import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; +import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; describe('Pipeline editor drawer', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js index edd2b45569a..f510c61ee74 100644 --- a/spec/frontend/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/drawer/ui/demo_job_pill_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import DemoJobPill from '~/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; +import DemoJobPill from '~/ci/pipeline_editor/components/drawer/ui/demo_job_pill.vue'; describe('Demo job pill', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js index 7dd8a77d055..2a2bc2547cc 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_config_merged_preview_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_config_merged_preview_spec.js @@ -2,7 +2,7 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; -import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; +import CiConfigMergedPreview from '~/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue'; import { mockLintResponse, mockCiConfigPath } from '../../mock_data'; describe('Text editor component', () => { diff --git a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js index 930f08ef545..d7f0ce838d6 100644 --- a/spec/frontend/pipeline_editor/components/editor/ci_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/ci_editor_header_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; +import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_header.vue'; import { pipelineEditorTrackingOptions, TEMPLATE_REPOSITORY_URL, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; describe('CI Editor Header', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js index 6cdf9a93d55..63e23c41263 100644 --- a/spec/frontend/pipeline_editor/components/editor/text_editor_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/editor/text_editor_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; import { EDITOR_READY_EVENT } from '~/editor/constants'; -import { SOURCE_EDITOR_DEBOUNCE } from '~/pipeline_editor/constants'; -import TextEditor from '~/pipeline_editor/components/editor/text_editor.vue'; +import { SOURCE_EDITOR_DEBOUNCE } from '~/ci/pipeline_editor/constants'; +import TextEditor from '~/ci/pipeline_editor/components/editor/text_editor.vue'; import { mockCiConfigPath, mockCiYml, diff --git a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js index f0347ad19ac..a26232df58f 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/branch_switcher_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-nav/branch_switcher_spec.js @@ -9,12 +9,12 @@ import { createLocalVue, mount, shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; -import { DEFAULT_FAILURE } from '~/pipeline_editor/constants'; -import getAvailableBranchesQuery from '~/pipeline_editor/graphql/queries/available_branches.query.graphql'; -import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; -import getLastCommitBranch from '~/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; -import { resolvers } from '~/pipeline_editor/graphql/resolvers'; +import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue'; +import { DEFAULT_FAILURE } from '~/ci/pipeline_editor/constants'; +import getAvailableBranchesQuery from '~/ci/pipeline_editor/graphql/queries/available_branches.query.graphql'; +import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getLastCommitBranch from '~/ci/pipeline_editor/graphql/queries/client/last_commit_branch.query.graphql'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import { mockBranchPaginationLimit, diff --git a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js index d503aff40b8..907db16913c 100644 --- a/spec/frontend/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-nav/pipeline_editor_file_nav_spec.js @@ -3,15 +3,15 @@ import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; -import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; -import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; -import FileTreePopover from '~/pipeline_editor/components/popovers/file_tree_popover.vue'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue'; +import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import FileTreePopover from '~/ci/pipeline_editor/components/popovers/file_tree_popover.vue'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import { EDITOR_APP_STATUS_EMPTY, EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_VALID, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; Vue.use(VueApollo); diff --git a/spec/frontend/pipeline_editor/components/file-tree/container_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js index f79074f1e0f..11ba517e0eb 100644 --- a/spec/frontend/pipeline_editor/components/file-tree/container_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-tree/container_spec.js @@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; import { GlAlert } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import { createMockDirective } from 'helpers/vue_mock_directive'; -import PipelineEditorFileTreeContainer from '~/pipeline_editor/components/file_tree/container.vue'; -import PipelineEditorFileTreeItem from '~/pipeline_editor/components/file_tree/file_item.vue'; -import { FILE_TREE_TIP_DISMISSED_KEY } from '~/pipeline_editor/constants'; +import PipelineEditorFileTreeContainer from '~/ci/pipeline_editor/components/file_tree/container.vue'; +import PipelineEditorFileTreeItem from '~/ci/pipeline_editor/components/file_tree/file_item.vue'; +import { FILE_TREE_TIP_DISMISSED_KEY } from '~/ci/pipeline_editor/constants'; import { mockCiConfigPath, mockIncludes, mockIncludesHelpPagePath } from '../../mock_data'; describe('Pipeline editor file nav', () => { diff --git a/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js index f12ac14c6be..bceb741f91c 100644 --- a/spec/frontend/pipeline_editor/components/file-tree/file_item_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/file-tree/file_item_spec.js @@ -1,7 +1,7 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import FileIcon from '~/vue_shared/components/file_icon.vue'; -import PipelineEditorFileTreeItem from '~/pipeline_editor/components/file_tree/file_item.vue'; +import PipelineEditorFileTreeItem from '~/ci/pipeline_editor/components/file_tree/file_item.vue'; import { mockIncludesWithBlob, mockDefaultIncludes } from '../../mock_data'; describe('Pipeline editor file nav', () => { diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js index e1dc08b637f..555b9f29fbf 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_editor_header_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_header_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; -import PipelineStatus from '~/pipeline_editor/components/header/pipeline_status.vue'; -import ValidationSegment from '~/pipeline_editor/components/header/validation_segment.vue'; +import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue'; +import PipelineStatus from '~/ci/pipeline_editor/components/header/pipeline_status.vue'; +import ValidationSegment from '~/ci/pipeline_editor/components/header/validation_segment.vue'; import { mockCiYml, mockLintResponse } from '../../mock_data'; diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js index d40a9cc8100..6f28362e478 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_editor_mini_graph_spec.js @@ -3,10 +3,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; +import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; -import { PIPELINE_FAILURE } from '~/pipeline_editor/constants'; +import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants'; import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data'; Vue.use(VueApollo); diff --git a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js index 35315db39f8..a62c51ffb59 100644 --- a/spec/frontend/pipeline_editor/components/header/pipeline_status_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipeline_status_spec.js @@ -4,9 +4,9 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import PipelineStatus, { i18n } from '~/pipeline_editor/components/header/pipeline_status.vue'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; -import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; +import PipelineStatus, { i18n } from '~/ci/pipeline_editor/components/header/pipeline_status.vue'; +import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import { mockCommitSha, mockProjectPipeline, mockProjectFullPath } from '../../mock_data'; Vue.use(VueApollo); diff --git a/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js b/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js index d40a9cc8100..6f28362e478 100644 --- a/spec/frontend/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/pipline_editor_mini_graph_spec.js @@ -3,10 +3,10 @@ import Vue from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import PipelineEditorMiniGraph from '~/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; +import PipelineEditorMiniGraph from '~/ci/pipeline_editor/components/header/pipeline_editor_mini_graph.vue'; import PipelineMiniGraph from '~/pipelines/components/pipeline_mini_graph/pipeline_mini_graph.vue'; import getLinkedPipelinesQuery from '~/projects/commit_box/info/graphql/queries/get_linked_pipelines.query.graphql'; -import { PIPELINE_FAILURE } from '~/pipeline_editor/constants'; +import { PIPELINE_FAILURE } from '~/ci/pipeline_editor/constants'; import { mockLinkedPipelines, mockProjectFullPath, mockProjectPipeline } from '../../mock_data'; Vue.use(VueApollo); diff --git a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js index 1ad621e6f45..0853a6f4ca4 100644 --- a/spec/frontend/pipeline_editor/components/header/validation_segment_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/header/validation_segment_spec.js @@ -8,8 +8,8 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import { sprintf } from '~/locale'; import ValidationSegment, { i18n, -} from '~/pipeline_editor/components/header/validation_segment.vue'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; +} from '~/ci/pipeline_editor/components/header/validation_segment.vue'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; import { CI_CONFIG_STATUS_INVALID, EDITOR_APP_STATUS_EMPTY, @@ -17,7 +17,7 @@ import { EDITOR_APP_STATUS_LOADING, EDITOR_APP_STATUS_LINT_UNAVAILABLE, EDITOR_APP_STATUS_VALID, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; import { mergeUnwrappedCiConfig, mockCiYml, diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js index 7f89eda4dff..d43bdec3a33 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_results_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_results_spec.js @@ -1,7 +1,7 @@ import { GlTableLite, GlLink } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; import { capitalizeFirstCharacter } from '~/lib/utils/text_utility'; -import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; import { mockJobs, mockErrors, mockWarnings } from '../../mock_data'; describe('CI Lint Results', () => { diff --git a/spec/frontend/pipeline_editor/components/lint/ci_lint_warnings_spec.js b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js index 36052a2e16a..b5e3ea06c2c 100644 --- a/spec/frontend/pipeline_editor/components/lint/ci_lint_warnings_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/lint/ci_lint_warnings_spec.js @@ -1,7 +1,7 @@ import { GlAlert, GlSprintf } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { trimText } from 'helpers/text_helper'; -import CiLintWarnings from '~/pipeline_editor/components/lint/ci_lint_warnings.vue'; +import CiLintWarnings from '~/ci/pipeline_editor/components/lint/ci_lint_warnings.vue'; const warnings = ['warning 1', 'warning 2', 'warning 3']; diff --git a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js index 27707f8b01a..70310cbdb10 100644 --- a/spec/frontend/pipeline_editor/components/pipeline_editor_tabs_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/pipeline_editor_tabs_spec.js @@ -6,11 +6,11 @@ import VueApollo from 'vue-apollo'; import Vue, { nextTick } from 'vue'; import createMockApollo from 'helpers/mock_apollo_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; -import CiConfigMergedPreview from '~/pipeline_editor/components/editor/ci_config_merged_preview.vue'; -import CiValidate from '~/pipeline_editor/components/validate/ci_validate.vue'; -import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue'; -import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; -import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; +import CiConfigMergedPreview from '~/ci/pipeline_editor/components/editor/ci_config_merged_preview.vue'; +import CiValidate from '~/ci/pipeline_editor/components/validate/ci_validate.vue'; +import WalkthroughPopover from '~/ci/pipeline_editor/components/popovers/walkthrough_popover.vue'; +import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; +import EditorTab from '~/ci/pipeline_editor/components/ui/editor_tab.vue'; import { CREATE_TAB, EDITOR_APP_STATUS_EMPTY, @@ -20,9 +20,9 @@ import { TAB_QUERY_PARAM, VALIDATE_TAB, VALIDATE_TAB_BADGE_DISMISSED_KEY, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; import PipelineGraph from '~/pipelines/components/pipeline_graph/pipeline_graph.vue'; -import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql'; import { mockBlobContentQueryResponse, mockCiLintPath, diff --git a/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js index 98ce3f6ea40..63ebfc0559d 100644 --- a/spec/frontend/pipeline_editor/components/popovers/file_tree_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/file_tree_popover_spec.js @@ -1,8 +1,8 @@ import { nextTick } from 'vue'; import { GlLink, GlPopover, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import FileTreePopover from '~/pipeline_editor/components/popovers/file_tree_popover.vue'; -import { FILE_TREE_POPOVER_DISMISSED_KEY } from '~/pipeline_editor/constants'; +import FileTreePopover from '~/ci/pipeline_editor/components/popovers/file_tree_popover.vue'; +import { FILE_TREE_POPOVER_DISMISSED_KEY } from '~/ci/pipeline_editor/constants'; import { mockIncludesHelpPagePath } from '../../mock_data'; describe('FileTreePopover component', () => { diff --git a/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js index 97f785a71bc..cf0b974081e 100644 --- a/spec/frontend/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/validate_pipeline_popover_spec.js @@ -1,7 +1,7 @@ import { GlLink, GlSprintf } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import ValidatePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; -import { VALIDATE_TAB_FEEDBACK_URL } from '~/pipeline_editor/constants'; +import ValidatePopover from '~/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; +import { VALIDATE_TAB_FEEDBACK_URL } from '~/ci/pipeline_editor/constants'; import { mockSimulatePipelineHelpPagePath } from '../../mock_data'; describe('ValidatePopover component', () => { diff --git a/spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js index b86c82850c5..ca6033f2ff5 100644 --- a/spec/frontend/pipeline_editor/components/popovers/walkthrough_popover_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/popovers/walkthrough_popover_spec.js @@ -1,6 +1,6 @@ import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; -import WalkthroughPopover from '~/pipeline_editor/components/popovers/walkthrough_popover.vue'; +import WalkthroughPopover from '~/ci/pipeline_editor/components/popovers/walkthrough_popover.vue'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; Vue.config.ignoredElements = ['gl-emoji']; diff --git a/spec/frontend/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js index 44fda2812d8..b22c98e5544 100644 --- a/spec/frontend/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import ConfirmDialog from '~/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue'; +import ConfirmDialog from '~/ci/pipeline_editor/components/ui/confirm_unsaved_changes_dialog.vue'; describe('pipeline_editor/components/ui/confirm_unsaved_changes_dialog', () => { let beforeUnloadEvent; diff --git a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js index 24f27e8c5fb..a4e7abba7b0 100644 --- a/spec/frontend/pipeline_editor/components/ui/editor_tab_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/editor_tab_spec.js @@ -1,7 +1,7 @@ import { GlAlert, GlBadge, GlTabs } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import EditorTab from '~/pipeline_editor/components/ui/editor_tab.vue'; +import EditorTab from '~/ci/pipeline_editor/components/ui/editor_tab.vue'; const mockContent1 = 'MOCK CONTENT 1'; const mockContent2 = 'MOCK CONTENT 2'; @@ -10,7 +10,7 @@ const MockSourceEditor = { template: '<div>EDITOR</div>', }; -describe('~/pipeline_editor/components/ui/editor_tab.vue', () => { +describe('~/ci/pipeline_editor/components/ui/editor_tab.vue', () => { let wrapper; let mockChildMounted = jest.fn(); diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js index c76c3460e99..3c68f74af43 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_empty_state_spec.js @@ -1,7 +1,7 @@ import { GlButton, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; -import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; +import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; describe('Pipeline editor empty state', () => { let wrapper; diff --git a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_messages_spec.js index d9ecee31e83..fdb3be5c690 100644 --- a/spec/frontend/pipeline_editor/components/ui/pipeline_editor_messages_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/ui/pipeline_editor_messages_spec.js @@ -2,9 +2,9 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import setWindowLocation from 'helpers/set_window_location_helper'; import { TEST_HOST } from 'helpers/test_constants'; -import CodeSnippetAlert from '~/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; -import { CODE_SNIPPET_SOURCES } from '~/pipeline_editor/components/code_snippet_alert/constants'; -import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; +import CodeSnippetAlert from '~/ci/pipeline_editor/components/code_snippet_alert/code_snippet_alert.vue'; +import { CODE_SNIPPET_SOURCES } from '~/ci/pipeline_editor/components/code_snippet_alert/constants'; +import PipelineEditorMessages from '~/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue'; import { COMMIT_FAILURE, COMMIT_SUCCESS, @@ -13,7 +13,7 @@ import { DEFAULT_SUCCESS, LOAD_FAILURE_UNKNOWN, PIPELINE_FAILURE, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; beforeEach(() => { setWindowLocation(TEST_HOST); diff --git a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js index 09d4f9736ad..ae25142b455 100644 --- a/spec/frontend/pipeline_editor/components/validate/ci_validate_spec.js +++ b/spec/frontend/ci/pipeline_editor/components/validate/ci_validate_spec.js @@ -5,12 +5,12 @@ import VueApollo from 'vue-apollo'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; -import CiLintResults from '~/pipeline_editor/components/lint/ci_lint_results.vue'; -import CiValidate, { i18n } from '~/pipeline_editor/components/validate/ci_validate.vue'; -import ValidatePipelinePopover from '~/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; -import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; -import lintCIMutation from '~/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; -import { pipelineEditorTrackingOptions } from '~/pipeline_editor/constants'; +import CiLintResults from '~/ci/pipeline_editor/components/lint/ci_lint_results.vue'; +import CiValidate, { i18n } from '~/ci/pipeline_editor/components/validate/ci_validate.vue'; +import ValidatePipelinePopover from '~/ci/pipeline_editor/components/popovers/validate_pipeline_popover.vue'; +import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import lintCIMutation from '~/ci/pipeline_editor/graphql/mutations/client/lint_ci.mutation.graphql'; +import { pipelineEditorTrackingOptions } from '~/ci/pipeline_editor/constants'; import { mockBlobContentQueryResponse, mockCiLintPath, diff --git a/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap b/spec/frontend/ci/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap index ee5a3cb288f..75a1354fd29 100644 --- a/spec/frontend/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap +++ b/spec/frontend/ci/pipeline_editor/graphql/__snapshots__/resolvers_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`~/pipeline_editor/graphql/resolvers Mutation lintCI lint data is as expected 1`] = ` +exports[`~/ci/pipeline_editor/graphql/resolvers Mutation lintCI lint data is as expected 1`] = ` Object { "__typename": "CiLintContent", "errors": Array [], diff --git a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js index 76ae96c623a..e54c72a758f 100644 --- a/spec/frontend/pipeline_editor/graphql/resolvers_spec.js +++ b/spec/frontend/ci/pipeline_editor/graphql/resolvers_spec.js @@ -1,7 +1,7 @@ import MockAdapter from 'axios-mock-adapter'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; -import { resolvers } from '~/pipeline_editor/graphql/resolvers'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; import { mockLintResponse } from '../mock_data'; jest.mock('~/api', () => { @@ -10,7 +10,7 @@ jest.mock('~/api', () => { }; }); -describe('~/pipeline_editor/graphql/resolvers', () => { +describe('~/ci/pipeline_editor/graphql/resolvers', () => { describe('Mutation', () => { describe('lintCI', () => { let mock; diff --git a/spec/frontend/pipeline_editor/mock_data.js b/spec/frontend/ci/pipeline_editor/mock_data.js index 2ea580b7b53..176dc24f169 100644 --- a/spec/frontend/pipeline_editor/mock_data.js +++ b/spec/frontend/ci/pipeline_editor/mock_data.js @@ -1,4 +1,4 @@ -import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; +import { CI_CONFIG_STATUS_INVALID, CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; import { unwrapStagesWithNeeds } from '~/pipelines/components/unwrapping_utils'; export const mockProjectNamespace = 'user1'; @@ -119,7 +119,7 @@ export const mockIncludes = [ ]; // Mock result of the graphql query at: -// app/assets/javascripts/pipeline_editor/graphql/queries/ci_config.graphql +// app/assets/javascripts/ci/pipeline_editor/graphql/queries/ci_config.graphql export const mockCiConfigQueryResponse = { data: { ciConfig: { diff --git a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js index 9fe1536d3f5..2246d0bbf7e 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_app_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_app_spec.js @@ -6,30 +6,30 @@ import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { objectToQuery, redirectTo } from '~/lib/utils/url_utility'; -import { resolvers } from '~/pipeline_editor/graphql/resolvers'; -import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; -import PipelineEditorEmptyState from '~/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; -import PipelineEditorMessages from '~/pipeline_editor/components/ui/pipeline_editor_messages.vue'; -import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; +import { resolvers } from '~/ci/pipeline_editor/graphql/resolvers'; +import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; +import PipelineEditorEmptyState from '~/ci/pipeline_editor/components/ui/pipeline_editor_empty_state.vue'; +import PipelineEditorMessages from '~/ci/pipeline_editor/components/ui/pipeline_editor_messages.vue'; +import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue'; import ValidationSegment, { i18n as validationSegmenti18n, -} from '~/pipeline_editor/components/header/validation_segment.vue'; +} from '~/ci/pipeline_editor/components/header/validation_segment.vue'; import { COMMIT_SUCCESS, COMMIT_SUCCESS_WITH_REDIRECT, COMMIT_FAILURE, EDITOR_APP_STATUS_LOADING, -} from '~/pipeline_editor/constants'; -import getBlobContent from '~/pipeline_editor/graphql/queries/blob_content.query.graphql'; -import getCiConfigData from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; -import getTemplate from '~/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; -import getLatestCommitShaQuery from '~/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql'; -import getPipelineQuery from '~/pipeline_editor/graphql/queries/pipeline.query.graphql'; -import getCurrentBranch from '~/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; -import getAppStatus from '~/pipeline_editor/graphql/queries/client/app_status.query.graphql'; - -import PipelineEditorApp from '~/pipeline_editor/pipeline_editor_app.vue'; -import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; +} from '~/ci/pipeline_editor/constants'; +import getBlobContent from '~/ci/pipeline_editor/graphql/queries/blob_content.query.graphql'; +import getCiConfigData from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql'; +import getTemplate from '~/ci/pipeline_editor/graphql/queries/get_starter_template.query.graphql'; +import getLatestCommitShaQuery from '~/ci/pipeline_editor/graphql/queries/latest_commit_sha.query.graphql'; +import getPipelineQuery from '~/ci/pipeline_editor/graphql/queries/pipeline.query.graphql'; +import getCurrentBranch from '~/ci/pipeline_editor/graphql/queries/client/current_branch.query.graphql'; +import getAppStatus from '~/ci/pipeline_editor/graphql/queries/client/app_status.query.graphql'; + +import PipelineEditorApp from '~/ci/pipeline_editor/pipeline_editor_app.vue'; +import PipelineEditorHome from '~/ci/pipeline_editor/pipeline_editor_home.vue'; import { mockCiConfigPath, diff --git a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js index 2b06660c4b3..621e015e825 100644 --- a/spec/frontend/pipeline_editor/pipeline_editor_home_spec.js +++ b/spec/frontend/ci/pipeline_editor/pipeline_editor_home_spec.js @@ -3,14 +3,14 @@ import { nextTick } from 'vue'; import { GlButton, GlDrawer, GlModal } from '@gitlab/ui'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import setWindowLocation from 'helpers/set_window_location_helper'; -import CiEditorHeader from '~/pipeline_editor/components/editor/ci_editor_header.vue'; -import CommitSection from '~/pipeline_editor/components/commit/commit_section.vue'; -import PipelineEditorDrawer from '~/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; -import PipelineEditorFileNav from '~/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; -import PipelineEditorFileTree from '~/pipeline_editor/components/file_tree/container.vue'; -import BranchSwitcher from '~/pipeline_editor/components/file_nav/branch_switcher.vue'; -import PipelineEditorHeader from '~/pipeline_editor/components/header/pipeline_editor_header.vue'; -import PipelineEditorTabs from '~/pipeline_editor/components/pipeline_editor_tabs.vue'; +import CiEditorHeader from '~/ci/pipeline_editor/components/editor/ci_editor_header.vue'; +import CommitSection from '~/ci/pipeline_editor/components/commit/commit_section.vue'; +import PipelineEditorDrawer from '~/ci/pipeline_editor/components/drawer/pipeline_editor_drawer.vue'; +import PipelineEditorFileNav from '~/ci/pipeline_editor/components/file_nav/pipeline_editor_file_nav.vue'; +import PipelineEditorFileTree from '~/ci/pipeline_editor/components/file_tree/container.vue'; +import BranchSwitcher from '~/ci/pipeline_editor/components/file_nav/branch_switcher.vue'; +import PipelineEditorHeader from '~/ci/pipeline_editor/components/header/pipeline_editor_header.vue'; +import PipelineEditorTabs from '~/ci/pipeline_editor/components/pipeline_editor_tabs.vue'; import { CREATE_TAB, FILE_TREE_DISPLAY_KEY, @@ -18,8 +18,8 @@ import { MERGED_TAB, TABS_INDEX, VISUALIZE_TAB, -} from '~/pipeline_editor/constants'; -import PipelineEditorHome from '~/pipeline_editor/pipeline_editor_home.vue'; +} from '~/ci/pipeline_editor/constants'; +import PipelineEditorHome from '~/ci/pipeline_editor/pipeline_editor_home.vue'; import { mockLintResponse, mockCiYml } from './mock_data'; diff --git a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js index e5d9b378a42..639c2dbef4c 100644 --- a/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js +++ b/spec/frontend/ci/pipeline_schedules/components/pipeline_schedules_form_spec.js @@ -1,25 +1,160 @@ -import { shallowMount } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; import { GlForm } from '@gitlab/ui'; +import { nextTick } from 'vue'; +import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import axios from '~/lib/utils/axios_utils'; import PipelineSchedulesForm from '~/ci/pipeline_schedules/components/pipeline_schedules_form.vue'; +import RefSelector from '~/ref/components/ref_selector.vue'; +import { REF_TYPE_BRANCHES, REF_TYPE_TAGS } from '~/ref/constants'; +import TimezoneDropdown from '~/vue_shared/components/timezone_dropdown/timezone_dropdown.vue'; +import IntervalPatternInput from '~/pages/projects/pipeline_schedules/shared/components/interval_pattern_input.vue'; +import { timezoneDataFixture } from '../../../vue_shared/components/timezone_dropdown/helpers'; describe('Pipeline schedules form', () => { let wrapper; + const defaultBranch = 'main'; + const projectId = '1'; + const cron = ''; + const dailyLimit = ''; - const createComponent = () => { - wrapper = shallowMount(PipelineSchedulesForm); + const createComponent = (mountFn = shallowMountExtended, stubs = {}) => { + wrapper = mountFn(PipelineSchedulesForm, { + propsData: { + timezoneData: timezoneDataFixture, + refParam: 'master', + }, + provide: { + fullPath: 'gitlab-org/gitlab', + projectId, + defaultBranch, + cron, + cronTimezone: '', + dailyLimit, + settingsLink: '', + }, + stubs, + }); }; const findForm = () => wrapper.findComponent(GlForm); + const findDescription = () => wrapper.findByTestId('schedule-description'); + const findIntervalComponent = () => wrapper.findComponent(IntervalPatternInput); + const findTimezoneDropdown = () => wrapper.findComponent(TimezoneDropdown); + const findRefSelector = () => wrapper.findComponent(RefSelector); + const findSubmitButton = () => wrapper.findByTestId('schedule-submit-button'); + const findCancelButton = () => wrapper.findByTestId('schedule-cancel-button'); + // Variables + const findVariableRows = () => wrapper.findAllByTestId('ci-variable-row'); + const findKeyInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-key'); + const findValueInputs = () => wrapper.findAllByTestId('pipeline-form-ci-variable-value'); + const findRemoveIcons = () => wrapper.findAllByTestId('remove-ci-variable-row'); beforeEach(() => { createComponent(); }); - afterEach(() => { - wrapper.destroy(); + describe('Form elements', () => { + it('displays form', () => { + expect(findForm().exists()).toBe(true); + }); + + it('displays the description input', () => { + expect(findDescription().exists()).toBe(true); + }); + + it('displays the interval pattern component', () => { + const intervalPattern = findIntervalComponent(); + + expect(intervalPattern.exists()).toBe(true); + expect(intervalPattern.props()).toMatchObject({ + initialCronInterval: cron, + dailyLimit, + sendNativeErrors: false, + }); + }); + + it('displays the Timezone dropdown', () => { + const timezoneDropdown = findTimezoneDropdown(); + + expect(timezoneDropdown.exists()).toBe(true); + expect(timezoneDropdown.props()).toMatchObject({ + value: '', + name: 'schedule-timezone', + timezoneData: timezoneDataFixture, + }); + }); + + it('displays the branch/tag selector', () => { + const refSelector = findRefSelector(); + + expect(refSelector.exists()).toBe(true); + expect(refSelector.props()).toMatchObject({ + enabledRefTypes: [REF_TYPE_BRANCHES, REF_TYPE_TAGS], + value: defaultBranch, + projectId, + translations: { dropdownHeader: 'Select target branch or tag' }, + useSymbolicRefNames: true, + state: true, + name: '', + }); + }); + + it('displays the submit and cancel buttons', () => { + expect(findSubmitButton().exists()).toBe(true); + expect(findCancelButton().exists()).toBe(true); + }); }); - it('displays form', () => { - expect(findForm().exists()).toBe(true); + describe('CI variables', () => { + let mock; + + const addVariableToForm = () => { + const input = findKeyInputs().at(0); + input.element.value = 'test_var_2'; + input.trigger('change'); + }; + + beforeEach(() => { + mock = new MockAdapter(axios); + createComponent(mountExtended); + }); + + afterEach(() => { + mock.restore(); + }); + + it('creates blank variable on input change event', async () => { + expect(findVariableRows()).toHaveLength(1); + + addVariableToForm(); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(2); + expect(findKeyInputs().at(1).element.value).toBe(''); + expect(findValueInputs().at(1).element.value).toBe(''); + }); + + it('does not display remove icon for last row', async () => { + addVariableToForm(); + + await nextTick(); + + expect(findRemoveIcons()).toHaveLength(1); + }); + + it('removes ci variable row on remove icon button click', async () => { + addVariableToForm(); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(2); + + findRemoveIcons().at(0).trigger('click'); + + await nextTick(); + + expect(findVariableRows()).toHaveLength(1); + }); }); }); diff --git a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js index c32b52d9e77..5ca4b25da9b 100644 --- a/spec/frontend/reports/codequality_report/components/codequality_issue_body_spec.js +++ b/spec/frontend/ci/reports/codequality_report/components/codequality_issue_body_spec.js @@ -1,8 +1,8 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import component from '~/reports/codequality_report/components/codequality_issue_body.vue'; -import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; +import component from '~/ci/reports/codequality_report/components/codequality_issue_body.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; describe('code quality issue body issue body', () => { let wrapper; diff --git a/spec/frontend/reports/codequality_report/mock_data.js b/spec/frontend/ci/reports/codequality_report/mock_data.js index 2c994116db6..2c994116db6 100644 --- a/spec/frontend/reports/codequality_report/mock_data.js +++ b/spec/frontend/ci/reports/codequality_report/mock_data.js diff --git a/spec/frontend/reports/codequality_report/store/actions_spec.js b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js index 1878b9f44b2..88628210793 100644 --- a/spec/frontend/reports/codequality_report/store/actions_spec.js +++ b/spec/frontend/ci/reports/codequality_report/store/actions_spec.js @@ -2,10 +2,10 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; -import createStore from '~/reports/codequality_report/store'; -import * as actions from '~/reports/codequality_report/store/actions'; -import * as types from '~/reports/codequality_report/store/mutation_types'; -import { STATUS_NOT_FOUND } from '~/reports/constants'; +import createStore from '~/ci/reports/codequality_report/store'; +import * as actions from '~/ci/reports/codequality_report/store/actions'; +import * as types from '~/ci/reports/codequality_report/store/mutation_types'; +import { STATUS_NOT_FOUND } from '~/ci/reports/constants'; import { reportIssues, parsedReportIssues } from '../mock_data'; const pollInterval = 123; diff --git a/spec/frontend/reports/codequality_report/store/getters_spec.js b/spec/frontend/ci/reports/codequality_report/store/getters_spec.js index 646903390ff..f4505204f67 100644 --- a/spec/frontend/reports/codequality_report/store/getters_spec.js +++ b/spec/frontend/ci/reports/codequality_report/store/getters_spec.js @@ -1,6 +1,6 @@ -import createStore from '~/reports/codequality_report/store'; -import * as getters from '~/reports/codequality_report/store/getters'; -import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/reports/constants'; +import createStore from '~/ci/reports/codequality_report/store'; +import * as getters from '~/ci/reports/codequality_report/store/getters'; +import { LOADING, ERROR, SUCCESS, STATUS_NOT_FOUND } from '~/ci/reports/constants'; describe('Codequality reports store getters', () => { let localState; diff --git a/spec/frontend/reports/codequality_report/store/mutations_spec.js b/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js index 6e14cd7438b..22ff86b1040 100644 --- a/spec/frontend/reports/codequality_report/store/mutations_spec.js +++ b/spec/frontend/ci/reports/codequality_report/store/mutations_spec.js @@ -1,6 +1,6 @@ -import createStore from '~/reports/codequality_report/store'; -import mutations from '~/reports/codequality_report/store/mutations'; -import { STATUS_NOT_FOUND } from '~/reports/constants'; +import createStore from '~/ci/reports/codequality_report/store'; +import mutations from '~/ci/reports/codequality_report/store/mutations'; +import { STATUS_NOT_FOUND } from '~/ci/reports/constants'; describe('Codequality Reports mutations', () => { let localState; diff --git a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js b/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js index 5b77a2c74be..f7d82d2b662 100644 --- a/spec/frontend/reports/codequality_report/store/utils/codequality_parser_spec.js +++ b/spec/frontend/ci/reports/codequality_report/store/utils/codequality_parser_spec.js @@ -1,5 +1,5 @@ -import { reportIssues, parsedReportIssues } from 'jest/reports/codequality_report/mock_data'; -import { parseCodeclimateMetrics } from '~/reports/codequality_report/store/utils/codequality_parser'; +import { reportIssues, parsedReportIssues } from 'jest/ci/reports/codequality_report/mock_data'; +import { parseCodeclimateMetrics } from '~/ci/reports/codequality_report/store/utils/codequality_parser'; describe('Codequality report store utils', () => { let result; diff --git a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap b/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap index 311a67a3e31..311a67a3e31 100644 --- a/spec/frontend/reports/components/__snapshots__/grouped_issues_list_spec.js.snap +++ b/spec/frontend/ci/reports/components/__snapshots__/grouped_issues_list_spec.js.snap diff --git a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap b/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap index b5a4cb42463..b5a4cb42463 100644 --- a/spec/frontend/reports/components/__snapshots__/issue_status_icon_spec.js.snap +++ b/spec/frontend/ci/reports/components/__snapshots__/issue_status_icon_spec.js.snap diff --git a/spec/frontend/reports/components/grouped_issues_list_spec.js b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js index cacbde590d6..3e4adfc7794 100644 --- a/spec/frontend/reports/components/grouped_issues_list_spec.js +++ b/spec/frontend/ci/reports/components/grouped_issues_list_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import GroupedIssuesList from '~/reports/components/grouped_issues_list.vue'; -import ReportItem from '~/reports/components/report_item.vue'; +import GroupedIssuesList from '~/ci/reports/components/grouped_issues_list.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; import SmartVirtualList from '~/vue_shared/components/smart_virtual_list.vue'; describe('Grouped Issues List', () => { diff --git a/spec/frontend/reports/components/issue_status_icon_spec.js b/spec/frontend/ci/reports/components/issue_status_icon_spec.js index 8706f2f8d83..fb13d4407e2 100644 --- a/spec/frontend/reports/components/issue_status_icon_spec.js +++ b/spec/frontend/ci/reports/components/issue_status_icon_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import ReportItem from '~/reports/components/issue_status_icon.vue'; -import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/reports/constants'; +import ReportItem from '~/ci/reports/components/issue_status_icon.vue'; +import { STATUS_FAILED, STATUS_NEUTRAL, STATUS_SUCCESS } from '~/ci/reports/constants'; describe('IssueStatusIcon', () => { let wrapper; diff --git a/spec/frontend/reports/components/report_item_spec.js b/spec/frontend/ci/reports/components/report_item_spec.js index 60c7e5f2b44..d835d549531 100644 --- a/spec/frontend/reports/components/report_item_spec.js +++ b/spec/frontend/ci/reports/components/report_item_spec.js @@ -1,8 +1,8 @@ import { shallowMount } from '@vue/test-utils'; -import { componentNames } from '~/reports/components/issue_body'; -import IssueStatusIcon from '~/reports/components/issue_status_icon.vue'; -import ReportItem from '~/reports/components/report_item.vue'; -import { STATUS_SUCCESS } from '~/reports/constants'; +import { componentNames } from '~/ci/reports/components/issue_body'; +import IssueStatusIcon from '~/ci/reports/components/issue_status_icon.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import { STATUS_SUCCESS } from '~/ci/reports/constants'; describe('ReportItem', () => { describe('showReportSectionStatusIcon', () => { diff --git a/spec/frontend/reports/components/report_link_spec.js b/spec/frontend/ci/reports/components/report_link_spec.js index 2ed0617a598..ba541ba0303 100644 --- a/spec/frontend/reports/components/report_link_spec.js +++ b/spec/frontend/ci/reports/components/report_link_spec.js @@ -1,7 +1,7 @@ import { shallowMount } from '@vue/test-utils'; -import ReportLink from '~/reports/components/report_link.vue'; +import ReportLink from '~/ci/reports/components/report_link.vue'; -describe('app/assets/javascripts/reports/components/report_link.vue', () => { +describe('app/assets/javascripts/ci/reports/components/report_link.vue', () => { let wrapper; afterEach(() => { diff --git a/spec/frontend/reports/components/report_section_spec.js b/spec/frontend/ci/reports/components/report_section_spec.js index cc35b99a199..f032b210184 100644 --- a/spec/frontend/reports/components/report_section_spec.js +++ b/spec/frontend/ci/reports/components/report_section_spec.js @@ -1,8 +1,8 @@ import { GlButton } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import ReportItem from '~/reports/components/report_item.vue'; -import ReportSection from '~/reports/components/report_section.vue'; +import ReportItem from '~/ci/reports/components/report_item.vue'; +import ReportSection from '~/ci/reports/components/report_section.vue'; describe('ReportSection component', () => { let wrapper; diff --git a/spec/frontend/reports/components/summary_row_spec.js b/spec/frontend/ci/reports/components/summary_row_spec.js index 778660d9e44..fb2ae5371d5 100644 --- a/spec/frontend/reports/components/summary_row_spec.js +++ b/spec/frontend/ci/reports/components/summary_row_spec.js @@ -1,7 +1,7 @@ import { mount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; -import SummaryRow from '~/reports/components/summary_row.vue'; +import SummaryRow from '~/ci/reports/components/summary_row.vue'; describe('Summary row', () => { let wrapper; diff --git a/spec/frontend/reports/mock_data/mock_data.js b/spec/frontend/ci/reports/mock_data/mock_data.js index 2599b0ac365..2599b0ac365 100644 --- a/spec/frontend/reports/mock_data/mock_data.js +++ b/spec/frontend/ci/reports/mock_data/mock_data.js diff --git a/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json b/spec/frontend/ci/reports/mock_data/new_and_fixed_failures_report.json index 6141e5433a6..9018ad5e4cf 100644 --- a/spec/frontend/reports/mock_data/new_and_fixed_failures_report.json +++ b/spec/frontend/ci/reports/mock_data/new_and_fixed_failures_report.json @@ -1,11 +1,21 @@ { "status": "failed", - "summary": { "total": 11, "resolved": 2, "errored": 0, "failed": 2 }, + "summary": { + "total": 11, + "resolved": 2, + "errored": 0, + "failed": 2 + }, "suites": [ { "name": "rspec:pg", "status": "failed", - "summary": { "total": 8, "resolved": 2, "errored": 0, "failed": 1 }, + "summary": { + "total": 8, + "resolved": 2, + "errored": 0, + "failed": 1 + }, "new_failures": [ { "status": "failed", @@ -36,7 +46,12 @@ { "name": "java ant", "status": "failed", - "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 1 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 1 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [ @@ -52,4 +67,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/reports/mock_data/new_errors_report.json b/spec/frontend/ci/reports/mock_data/new_errors_report.json index 6573d23ee50..d3fb570c327 100644 --- a/spec/frontend/reports/mock_data/new_errors_report.json +++ b/spec/frontend/ci/reports/mock_data/new_errors_report.json @@ -1,9 +1,19 @@ { - "summary": { "total": 11, "resolved": 0, "errored": 2, "failed": 0 }, + "summary": { + "total": 11, + "resolved": 0, + "errored": 2, + "failed": 0 + }, "suites": [ { "name": "karma", - "summary": { "total": 3, "resolved": 0, "errored": 2, "failed": 0 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 2, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -26,7 +36,12 @@ }, { "name": "rspec:pg", - "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -35,4 +50,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/reports/mock_data/new_failures_report.json b/spec/frontend/ci/reports/mock_data/new_failures_report.json index 438f7c82788..03a875b7636 100644 --- a/spec/frontend/reports/mock_data/new_failures_report.json +++ b/spec/frontend/ci/reports/mock_data/new_failures_report.json @@ -1,9 +1,19 @@ { - "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 2 }, + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 2 + }, "suites": [ { "name": "rspec:pg", - "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2 }, + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 2 + }, "new_failures": [ { "result": "failure", @@ -28,7 +38,12 @@ }, { "name": "java ant", - "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -37,4 +52,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/reports/mock_data/new_failures_with_null_files_report.json b/spec/frontend/ci/reports/mock_data/new_failures_with_null_files_report.json index 28ee7d194b9..00a35a3d0a7 100644 --- a/spec/frontend/reports/mock_data/new_failures_with_null_files_report.json +++ b/spec/frontend/ci/reports/mock_data/new_failures_with_null_files_report.json @@ -1,9 +1,19 @@ { - "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 2 }, + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 2 + }, "suites": [ { "name": "rspec:pg", - "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2 }, + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 2 + }, "new_failures": [ { "result": "failure", @@ -28,7 +38,12 @@ }, { "name": "java ant", - "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -37,4 +52,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/reports/mock_data/no_failures_report.json b/spec/frontend/ci/reports/mock_data/no_failures_report.json index 7da9e0c6211..a48a206208d 100644 --- a/spec/frontend/reports/mock_data/no_failures_report.json +++ b/spec/frontend/ci/reports/mock_data/no_failures_report.json @@ -1,11 +1,21 @@ { "status": "success", - "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "suites": [ { "name": "rspec:pg", "status": "success", - "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -16,7 +26,12 @@ { "name": "java ant", "status": "success", - "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -25,4 +40,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/reports/mock_data/recent_failures_report.json b/spec/frontend/ci/reports/mock_data/recent_failures_report.json index c4a5fb78dcd..f4fc2d2e927 100644 --- a/spec/frontend/reports/mock_data/recent_failures_report.json +++ b/spec/frontend/ci/reports/mock_data/recent_failures_report.json @@ -1,9 +1,21 @@ { - "summary": { "total": 11, "resolved": 0, "errored": 0, "failed": 3, "recentlyFailed": 2 }, + "summary": { + "total": 11, + "resolved": 0, + "errored": 0, + "failed": 3, + "recentlyFailed": 2 + }, "suites": [ { "name": "rspec:pg", - "summary": { "total": 8, "resolved": 0, "errored": 0, "failed": 2, "recentlyFailed": 1 }, + "summary": { + "total": 8, + "resolved": 0, + "errored": 0, + "failed": 2, + "recentlyFailed": 1 + }, "new_failures": [ { "result": "failure", @@ -30,7 +42,13 @@ }, { "name": "java ant", - "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 1, "recentlyFailed": 1 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 1, + "recentlyFailed": 1 + }, "new_failures": [ { "result": "failure", @@ -49,4 +67,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/reports/mock_data/resolved_failures.json b/spec/frontend/ci/reports/mock_data/resolved_failures.json index 49de6aa840b..15012fb027d 100644 --- a/spec/frontend/reports/mock_data/resolved_failures.json +++ b/spec/frontend/ci/reports/mock_data/resolved_failures.json @@ -1,11 +1,21 @@ { "status": "success", - "summary": { "total": 11, "resolved": 4, "errored": 0, "failed": 0 }, + "summary": { + "total": 11, + "resolved": 4, + "errored": 0, + "failed": 0 + }, "suites": [ { "name": "rspec:pg", "status": "success", - "summary": { "total": 8, "resolved": 4, "errored": 0, "failed": 0 }, + "summary": { + "total": 8, + "resolved": 4, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [ { @@ -18,7 +28,7 @@ { "status": "success", "name": "Test#sum when a is 100 and b is 200 returns summary", - "execution_time": 7.6e-5, + "execution_time": 0.000076, "system_output": null, "stack_trace": null } @@ -46,7 +56,12 @@ { "name": "java ant", "status": "success", - "summary": { "total": 3, "resolved": 0, "errored": 0, "failed": 0 }, + "summary": { + "total": 3, + "resolved": 0, + "errored": 0, + "failed": 0 + }, "new_failures": [], "resolved_failures": [], "existing_failures": [], @@ -55,4 +70,4 @@ "existing_errors": [] } ] -} +}
\ No newline at end of file diff --git a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js index 7081bc57467..e233268b756 100644 --- a/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js +++ b/spec/frontend/ci/runner/admin_runner_show/admin_runner_show_app_spec.js @@ -1,6 +1,8 @@ import Vue from 'vue'; import { GlTab, GlTabs } from '@gitlab/ui'; +import VueRouter from 'vue-router'; import VueApollo from 'vue-apollo'; +import setWindowLocation from 'helpers/set_window_location_helper'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -33,12 +35,15 @@ const mockRunnerId = `${getIdFromGraphQLId(mockRunnerGraphqlId)}`; const mockRunnersPath = '/admin/runners'; Vue.use(VueApollo); +Vue.use(VueRouter); describe('AdminRunnerShowApp', () => { let wrapper; let mockRunnerQuery; const findRunnerHeader = () => wrapper.findComponent(RunnerHeader); + const findTabs = () => wrapper.findComponent(GlTabs); + const findTabAt = (i) => wrapper.findAllComponents(GlTab).at(i); const findRunnerDetails = () => wrapper.findComponent(RunnerDetails); const findRunnerDeleteButton = () => wrapper.findComponent(RunnerDeleteButton); const findRunnerEditButton = () => wrapper.findComponent(RunnerEditButton); @@ -113,6 +118,16 @@ describe('AdminRunnerShowApp', () => { expect(wrapper.text().replace(/\s+/g, ' ')).toContain(expected); }); + it.each(['#/', '#/unknown-tab'])('shows details when location hash is `%s`', async (hash) => { + setWindowLocation(hash); + + await createComponent({ mountFn: mountExtended }); + + expect(findTabs().props('value')).toBe(0); + expect(findRunnerDetails().exists()).toBe(true); + expect(findRunnersJobs().exists()).toBe(false); + }); + describe('when runner cannot be updated', () => { beforeEach(async () => { mockRunnerQueryResult({ @@ -226,7 +241,7 @@ describe('AdminRunnerShowApp', () => { }); }); - describe('Jobs tab', () => { + describe('When showing jobs', () => { const stubs = { GlTab, GlTabs, @@ -245,6 +260,17 @@ describe('AdminRunnerShowApp', () => { expect(findRunnersJobs().exists()).toBe(false); }); + it('when URL hash links to jobs tab', async () => { + mockRunnerQueryResult(); + setWindowLocation('#/jobs'); + + await createComponent({ mountFn: mountExtended }); + + expect(findTabs().props('value')).toBe(1); + expect(findRunnerDetails().exists()).toBe(false); + expect(findRunnersJobs().exists()).toBe(true); + }); + it('without a job count, shows no jobs count', async () => { mockRunnerQueryResult({ jobCount: null }); @@ -260,7 +286,28 @@ describe('AdminRunnerShowApp', () => { await createComponent({ stubs }); expect(findJobCountBadge().text()).toBe('3'); - expect(findRunnersJobs().props('runner')).toEqual({ ...mockRunner, ...runner }); + }); + }); + + describe('When navigating to another tab', () => { + let routerPush; + + beforeEach(async () => { + mockRunnerQueryResult(); + + await createComponent({ mountFn: mountExtended }); + + routerPush = jest.spyOn(wrapper.vm.$router, 'push').mockImplementation(() => {}); + }); + + it('navigates to details', () => { + findTabAt(0).vm.$emit('click'); + expect(routerPush).toHaveBeenLastCalledWith({ name: 'details' }); + }); + + it('navigates to job', () => { + findTabAt(1).vm.$emit('click'); + expect(routerPush).toHaveBeenLastCalledWith({ name: 'jobs' }); }); }); }); diff --git a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js index 9778a6fe66c..9084ecdb4cc 100644 --- a/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js +++ b/spec/frontend/ci/runner/admin_runners/admin_runners_app_spec.js @@ -25,6 +25,7 @@ import RunnerStats from '~/ci/runner/components/stat/runner_stats.vue'; import RunnerActionsCell from '~/ci/runner/components/cells/runner_actions_cell.vue'; import RegistrationDropdown from '~/ci/runner/components/registration/registration_dropdown.vue'; import RunnerPagination from '~/ci/runner/components/runner_pagination.vue'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; import { ADMIN_FILTERED_SEARCH_NAMESPACE, @@ -77,7 +78,9 @@ jest.mock('~/lib/utils/url_utility', () => ({ Vue.use(VueApollo); Vue.use(GlToast); -const COUNT_QUERIES = 7; // 4 tabs + 3 status queries +const STATUS_COUNT_QUERIES = 3; +const TAB_COUNT_QUERIES = 4; +const COUNT_QUERIES = TAB_COUNT_QUERIES + STATUS_COUNT_QUERIES; describe('AdminRunnersApp', () => { let wrapper; @@ -170,6 +173,29 @@ describe('AdminRunnersApp', () => { }); }); + describe('does not show total runner counts when total is 0', () => { + beforeEach(async () => { + mockRunnersCountHandler.mockResolvedValue({ + data: { + runners: { + count: 0, + ...runnersCountData.runners, + }, + }, + }); + + await createComponent({ mountFn: mountExtended }); + }); + + it('fetches only tab counts', () => { + expect(mockRunnersCountHandler).toHaveBeenCalledTimes(TAB_COUNT_QUERIES); + }); + + it('does not shows counters', () => { + expect(findRunnerStats().text()).toBe(''); + }); + }); + it('shows the runners list', async () => { await createComponent(); @@ -252,6 +278,15 @@ describe('AdminRunnersApp', () => { expect(runnerLink.attributes('href')).toBe(`http://localhost/admin/runners/${id}`); }); + it('Shows job status and links to jobs', () => { + const badge = wrapper + .find('tr [data-testid="td-summary"]') + .findComponent(RunnerJobStatusBadge); + + expect(badge.props('jobStatus')).toBe(mockRunners[0].jobExecutionStatus); + expect(badge.attributes('href')).toBe(`http://localhost/admin/runners/${id}#/jobs`); + }); + it('When runner is paused or unpaused, some data is refetched', async () => { expect(mockRunnersCountHandler).toHaveBeenCalledTimes(COUNT_QUERIES); diff --git a/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js index 4aa354f9b62..10280c77303 100644 --- a/spec/frontend/ci/runner/components/cells/runner_stacked_summary_cell_spec.js +++ b/spec/frontend/ci/runner/components/cells/runner_summary_cell_spec.js @@ -1,12 +1,18 @@ import { __ } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import RunnerStackedSummaryCell from '~/ci/runner/components/cells/runner_stacked_summary_cell.vue'; +import RunnerSummaryCell from '~/ci/runner/components/cells/runner_summary_cell.vue'; import TimeAgo from '~/vue_shared/components/time_ago_tooltip.vue'; import RunnerTags from '~/ci/runner/components/runner_tags.vue'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; import RunnerSummaryField from '~/ci/runner/components/cells/runner_summary_field.vue'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { INSTANCE_TYPE, I18N_INSTANCE_TYPE, PROJECT_TYPE } from '~/ci/runner/constants'; +import { + INSTANCE_TYPE, + I18N_INSTANCE_TYPE, + PROJECT_TYPE, + I18N_NO_DESCRIPTION, +} from '~/ci/runner/constants'; import { allRunnersData } from '../../mock_data'; @@ -16,13 +22,14 @@ describe('RunnerTypeCell', () => { let wrapper; const findLockIcon = () => wrapper.findByTestId('lock-icon'); + const findRunnerJobStatusBadge = () => wrapper.findComponent(RunnerJobStatusBadge); const findRunnerTags = () => wrapper.findComponent(RunnerTags); const findRunnerSummaryField = (icon) => wrapper.findAllComponents(RunnerSummaryField).filter((w) => w.props('icon') === icon) .wrappers[0]; const createComponent = (runner, options) => { - wrapper = mountExtended(RunnerStackedSummaryCell, { + wrapper = mountExtended(RunnerSummaryCell, { propsData: { runner: { ...mockRunner, @@ -80,6 +87,18 @@ describe('RunnerTypeCell', () => { expect(wrapper.text()).toContain(mockRunner.description); }); + it('Displays the no runner description', () => { + createComponent({ + description: null, + }); + + expect(wrapper.text()).toContain(I18N_NO_DESCRIPTION); + }); + + it('Displays job execution status', () => { + expect(findRunnerJobStatusBadge().props('jobStatus')).toBe(mockRunner.jobExecutionStatus); + }); + it('Displays last contact', () => { createComponent({ contactedAt: '2022-01-02', @@ -147,14 +166,14 @@ describe('RunnerTypeCell', () => { expect(findRunnerTags().props('tagList')).toEqual(['shell', 'linux']); }); - it('Displays a custom slot', () => { + it.each(['runner-name', 'runner-job-status-badge'])('Displays a custom "%s" slot', (slotName) => { const slotContent = 'My custom runner name'; createComponent( {}, { slots: { - 'runner-name': slotContent, + [slotName]: slotContent, }, }, ); diff --git a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js index 496c144083e..408750e646f 100644 --- a/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js +++ b/spec/frontend/ci/runner/components/runner_filtered_search_bar_spec.js @@ -13,6 +13,7 @@ import { DEFAULT_SORT, CONTACTED_DESC, } from '~/ci/runner/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearch from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; @@ -34,7 +35,7 @@ describe('RunnerList', () => { const mockOtherSort = CONTACTED_DESC; const mockFilters = [ { type: PARAM_KEY_STATUS, value: { data: STATUS_ONLINE, operator: '=' } }, - { type: 'filtered-search-term', value: { data: '' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, ]; const expectToHaveLastEmittedInput = (value) => { diff --git a/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js new file mode 100644 index 00000000000..015bebf40e3 --- /dev/null +++ b/spec/frontend/ci/runner/components/runner_job_status_badge_spec.js @@ -0,0 +1,51 @@ +import { GlBadge } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import RunnerJobStatusBadge from '~/ci/runner/components/runner_job_status_badge.vue'; +import { + I18N_JOB_STATUS_RUNNING, + I18N_JOB_STATUS_IDLE, + JOB_STATUS_RUNNING, + JOB_STATUS_IDLE, +} from '~/ci/runner/constants'; + +describe('RunnerTypeBadge', () => { + let wrapper; + + const findBadge = () => wrapper.findComponent(GlBadge); + + const createComponent = ({ props, ...options } = {}) => { + wrapper = shallowMount(RunnerJobStatusBadge, { + propsData: { + ...props, + }, + ...options, + }); + }; + + it.each` + jobStatus | classes | text + ${JOB_STATUS_RUNNING} | ${['gl-mr-3', 'gl-bg-transparent!', 'gl-text-blue-600!', 'gl-border', 'gl-border-blue-600!']} | ${I18N_JOB_STATUS_RUNNING} + ${JOB_STATUS_IDLE} | ${['gl-mr-3', 'gl-bg-transparent!', 'gl-text-gray-700!', 'gl-border', 'gl-border-gray-500!']} | ${I18N_JOB_STATUS_IDLE} + `( + 'renders $jobStatus job status with "$text" text and styles', + ({ jobStatus, classes, text }) => { + createComponent({ props: { jobStatus } }); + + expect(findBadge().props()).toMatchObject({ size: 'sm', variant: 'muted' }); + expect(findBadge().classes().sort()).toEqual(classes.sort()); + expect(findBadge().text()).toBe(text); + }, + ); + + it('does not render an unknown status', () => { + createComponent({ props: { jobStatus: 'UNKNOWN_STATUS' } }); + + expect(wrapper.html()).toBe(''); + }); + + it('adds arbitrary attributes', () => { + createComponent({ props: { jobStatus: JOB_STATUS_RUNNING }, attrs: { href: '/url' } }); + + expect(findBadge().attributes('href')).toBe('/url'); + }); +}); diff --git a/spec/frontend/ci/runner/components/runner_list_spec.js b/spec/frontend/ci/runner/components/runner_list_spec.js index d53a0ce8f4f..1267d045623 100644 --- a/spec/frontend/ci/runner/components/runner_list_spec.js +++ b/spec/frontend/ci/runner/components/runner_list_spec.js @@ -188,6 +188,21 @@ describe('RunnerList', () => { expect(findCell({ fieldKey: 'summary' }).text()).toContain(`Summary: ${mockRunners[0].id}`); }); + it('Render #runner-job-status-badge slot in "summary" cell', () => { + createComponent( + { + scopedSlots: { + 'runner-job-status-badge': ({ runner }) => `Job status ${runner.jobExecutionStatus}`, + }, + }, + mountExtended, + ); + + expect(findCell({ fieldKey: 'summary' }).text()).toContain( + `Job status ${mockRunners[0].jobExecutionStatus}`, + ); + }); + it('Render #runner-actions-cell slot in "actions" cell', () => { createComponent( { diff --git a/spec/frontend/ci/runner/components/runner_status_badge_spec.js b/spec/frontend/ci/runner/components/runner_status_badge_spec.js index 7d3064c2aef..45b410df2d4 100644 --- a/spec/frontend/ci/runner/components/runner_status_badge_spec.js +++ b/spec/frontend/ci/runner/components/runner_status_badge_spec.js @@ -37,12 +37,12 @@ describe('RunnerTypeBadge', () => { }; beforeEach(() => { - jest.useFakeTimers('modern'); + jest.useFakeTimers({ legacyFakeTimers: false }); jest.setSystemTime(new Date('2021-01-01T00:00:00Z')); }); afterEach(() => { - jest.useFakeTimers('legacy'); + jest.useFakeTimers({ legacyFakeTimers: true }); wrapper.destroy(); }); diff --git a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js index d3c7ea50f9d..3dce5a509ca 100644 --- a/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js +++ b/spec/frontend/ci/runner/components/search_tokens/tag_token_spec.js @@ -7,7 +7,7 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import TagToken, { TAG_SUGGESTIONS_PATH } from '~/ci/runner/components/search_tokens/tag_token.vue'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; jest.mock('~/flash'); @@ -42,7 +42,7 @@ const mockTagTokenConfig = { type: 'tag', token: TagToken, recentSuggestionsStorageKey: mockStorageKey, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }; describe('TagToken', () => { diff --git a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js index daebf3df050..3d45674d106 100644 --- a/spec/frontend/ci/runner/components/stat/runner_stats_spec.js +++ b/spec/frontend/ci/runner/components/stat/runner_stats_spec.js @@ -16,6 +16,23 @@ describe('RunnerStats', () => { const findSingleStats = () => wrapper.findAllComponents(RunnerSingleStat); + const RunnerCountStub = { + props: ['variables'], + render() { + // return a count for each status + const mockCounts = { + undefined: 6, // no status returns "all" + [STATUS_ONLINE]: 3, + [STATUS_OFFLINE]: 2, + [STATUS_STALE]: 1, + }; + + return this.$scopedSlots.default({ + count: mockCounts[this.variables.status], + }); + }, + }; + const createComponent = ({ props = {}, mountFn = shallowMount, ...options } = {}) => { wrapper = mountFn(RunnerStats, { propsData: { @@ -23,6 +40,9 @@ describe('RunnerStats', () => { variables: {}, ...props, }, + stubs: { + RunnerCount: RunnerCountStub, + }, ...options, }); }; @@ -32,24 +52,8 @@ describe('RunnerStats', () => { }); it('Displays all the stats', () => { - const mockCounts = { - [STATUS_ONLINE]: 3, - [STATUS_OFFLINE]: 2, - [STATUS_STALE]: 1, - }; - createComponent({ mountFn: mount, - stubs: { - RunnerCount: { - props: ['variables'], - render() { - return this.$scopedSlots.default({ - count: mockCounts[this.variables.status], - }); - }, - }, - }, }); const text = wrapper.text(); @@ -78,4 +82,21 @@ describe('RunnerStats', () => { expect(stat.props('variables')).toMatchObject(mockVariables); }); }); + + it('Does not display counts when total is 0', () => { + createComponent({ + mountFn: mount, + stubs: { + RunnerCount: { + render() { + return this.$scopedSlots.default({ + count: 0, + }); + }, + }, + }, + }); + + expect(wrapper.html()).toBe(''); + }); }); diff --git a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js index c3493b3c9fd..1e5bb828dbf 100644 --- a/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js +++ b/spec/frontend/ci/runner/group_runners/group_runners_app_spec.js @@ -448,13 +448,15 @@ describe('GroupRunnersApp', () => { it('navigates to the next page', async () => { await findRunnerPaginationNext().trigger('click'); - expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith({ - groupFullPath: mockGroupFullPath, - membership: MEMBERSHIP_DESCENDANTS, - sort: CREATED_DESC, - first: RUNNER_PAGE_SIZE, - after: pageInfo.endCursor, - }); + expect(mockGroupRunnersHandler).toHaveBeenLastCalledWith( + expect.objectContaining({ + groupFullPath: mockGroupFullPath, + membership: MEMBERSHIP_DESCENDANTS, + sort: CREATED_DESC, + first: RUNNER_PAGE_SIZE, + after: pageInfo.endCursor, + }), + ); }); }); diff --git a/spec/frontend/ci/runner/mock_data.js b/spec/frontend/ci/runner/mock_data.js index eff5abc21b5..525756ed513 100644 --- a/spec/frontend/ci/runner/mock_data.js +++ b/spec/frontend/ci/runner/mock_data.js @@ -18,6 +18,7 @@ import groupRunnersDataPaginated from 'test_fixtures/graphql/ci/runner/list/grou import groupRunnersCountData from 'test_fixtures/graphql/ci/runner/list/group_runners_count.query.graphql.json'; import { DEFAULT_MEMBERSHIP, RUNNER_PAGE_SIZE } from '~/ci/runner/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; const emptyPageInfo = { __typename: 'PageInfo', @@ -73,7 +74,7 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'something' }, }, ], @@ -95,11 +96,11 @@ export const mockSearchExamples = [ membership: DEFAULT_MEMBERSHIP, filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'something' }, }, { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'else' }, }, ], diff --git a/spec/frontend/ci/runner/runner_search_utils_spec.js b/spec/frontend/ci/runner/runner_search_utils_spec.js index 1db8fa1829b..f64b89d47fd 100644 --- a/spec/frontend/ci/runner/runner_search_utils_spec.js +++ b/spec/frontend/ci/runner/runner_search_utils_spec.js @@ -6,6 +6,7 @@ import { fromSearchToVariables, isSearchFiltered, } from 'ee_else_ce/ci/runner/runner_search_utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { mockSearchExamples } from './mock_data'; describe('search_params.js', () => { @@ -48,8 +49,8 @@ describe('search_params.js', () => { it('When search params appear as array, they are concatenated', () => { expect(fromUrlQueryToSearch('?search[]=my&search[]=text').filters).toEqual([ - { type: 'filtered-search-term', value: { data: 'my' } }, - { type: 'filtered-search-term', value: { data: 'text' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'my' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'text' } }, ]); }); }); @@ -64,12 +65,13 @@ describe('search_params.js', () => { it.each([ 'http://test.host/?status[]=ACTIVE', 'http://test.host/?runner_type[]=INSTANCE_TYPE', + 'http://test.host/?paused[]=true', 'http://test.host/?search=my_text', - ])('When a filter is removed, it is removed from the URL', (initalUrl) => { + ])('When a filter is removed, it is removed from the URL', (initialUrl) => { const search = { filters: [], sort: 'CREATED_DESC' }; const expectedUrl = `http://test.host/`; - expect(fromSearchToUrl(search, initalUrl)).toBe(expectedUrl); + expect(fromSearchToUrl(search, initialUrl)).toBe(expectedUrl); }); it('When unrelated search parameter is present, it does not get removed', () => { @@ -93,7 +95,7 @@ describe('search_params.js', () => { fromSearchToVariables({ filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: '' }, }, ], @@ -106,11 +108,11 @@ describe('search_params.js', () => { fromSearchToVariables({ filters: [ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'something' }, }, { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: '' }, }, ], diff --git a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js index c7375acd8e5..aa83638773d 100644 --- a/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_admin_variables_spec.js @@ -24,6 +24,7 @@ describe('Ci Project Variable wrapper', () => { expect(findCiShared().props()).toEqual({ areScopedVariablesAvailable: false, componentName: 'InstanceVariables', + entity: '', hideEnvironmentScope: true, mutationData: wrapper.vm.$options.mutationData, queryData: wrapper.vm.$options.queryData, diff --git a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js index ef5a86ccb61..ef624d8e4b4 100644 --- a/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_group_variables_spec.js @@ -39,6 +39,7 @@ describe('Ci Group Variable wrapper', () => { id: convertToGraphQLId(GRAPHQL_GROUP_TYPE, mockProvide.groupId), areScopedVariablesAvailable: false, componentName: 'GroupVariables', + entity: 'group', fullPath: mockProvide.groupPath, hideEnvironmentScope: false, mutationData: wrapper.vm.$options.mutationData, diff --git a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js b/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js index 97051325f59..53c25e430f2 100644 --- a/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_project_variables_spec.js @@ -35,6 +35,7 @@ describe('Ci Project Variable wrapper', () => { id: convertToGraphQLId(GRAPHQL_PROJECT_TYPE, mockProvide.projectId), areScopedVariablesAvailable: true, componentName: 'ProjectVariables', + entity: 'project', fullPath: mockProvide.projectFullPath, hideEnvironmentScope: false, mutationData: wrapper.vm.$options.mutationData, 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 e4771f040d1..d177e755591 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 @@ -68,6 +68,7 @@ describe('Ci variable modal', () => { findModal() .findAllComponents(GlButton) .wrappers.find((button) => button.props('variant') === 'danger'); + const findExpandedVariableCheckbox = () => wrapper.findByTestId('ci-variable-expanded-checkbox'); const findProtectedVariableCheckbox = () => wrapper.findByTestId('ci-variable-protected-checkbox'); const findMaskedVariableCheckbox = () => wrapper.findByTestId('ci-variable-masked-checkbox'); @@ -75,6 +76,7 @@ describe('Ci variable modal', () => { const findEnvScopeLink = () => wrapper.findByTestId('environment-scope-link'); const findEnvScopeInput = () => wrapper.findByTestId('environment-scope').findComponent(GlFormInput); + const findRawVarTip = () => wrapper.findByTestId('raw-variable-tip'); const findVariableTypeDropdown = () => wrapper.find('#ci-variable-type'); const findEnvironmentScopeText = () => wrapper.findByText('Environment scope'); @@ -188,7 +190,7 @@ describe('Ci variable modal', () => { }); }); - describe('Reference warning when adding a variable', () => { + describe('when expanded', () => { describe('with a $ character', () => { beforeEach(() => { const [variable] = mockVariables; @@ -205,6 +207,10 @@ describe('Ci variable modal', () => { it(`renders the variable reference warning`, () => { expect(findReferenceWarning().exists()).toBe(true); }); + + it(`does not render raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(false); + }); }); describe('without a $ character', () => { @@ -219,6 +225,73 @@ describe('Ci variable modal', () => { it(`does not render the variable reference warning`, () => { expect(findReferenceWarning().exists()).toBe(false); }); + + it(`does not render raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(false); + }); + }); + + describe('setting raw value', () => { + const [variable] = mockVariables; + + it('defaults to expanded and raw:false when adding a variable', () => { + createComponent({ props: { selectedVariable: variable } }); + jest.spyOn(wrapper.vm, '$emit'); + + findModal().vm.$emit('shown'); + + expect(findExpandedVariableCheckbox().attributes('checked')).toBe('true'); + + findAddorUpdateButton().vm.$emit('click'); + + expect(wrapper.emitted('add-variable')).toEqual([ + [ + { + ...variable, + raw: false, + }, + ], + ]); + }); + + it('sets correct raw value when editing', async () => { + createComponent({ + props: { + selectedVariable: variable, + mode: EDIT_VARIABLE_ACTION, + }, + }); + jest.spyOn(wrapper.vm, '$emit'); + + findModal().vm.$emit('shown'); + await findExpandedVariableCheckbox().vm.$emit('change'); + await findAddorUpdateButton().vm.$emit('click'); + + expect(wrapper.emitted('update-variable')).toEqual([ + [ + { + ...variable, + raw: true, + }, + ], + ]); + }); + }); + }); + + describe('when not expanded', () => { + describe('with a $ character', () => { + beforeEach(() => { + const selectedVariable = mockVariables[1]; + createComponent({ + mountFn: mountExtended, + props: { selectedVariable }, + }); + }); + + it(`renders raw variable tip`, () => { + expect(findRawVarTip().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js index 8b5a0f7ae9d..5e459ee390f 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_settings_spec.js @@ -17,40 +17,45 @@ describe('Ci variable table', () => { const defaultProps = { areScopedVariablesAvailable: true, + entity: 'project', environments: mapEnvironmentNames(mockEnvs), hideEnvironmentScope: false, isLoading: false, + maxVariableLimit: 5, variables: mockVariablesWithScopes(projectString), }; const findCiVariableTable = () => wrapper.findComponent(ciVariableTable); const findCiVariableModal = () => wrapper.findComponent(ciVariableModal); - const createComponent = () => { + const createComponent = ({ props = {} } = {}) => { wrapper = shallowMount(CiVariableSettings, { propsData: { ...defaultProps, + ...props, }, }); }; - beforeEach(() => { - createComponent(); - }); - afterEach(() => { wrapper.destroy(); }); describe('props passing', () => { it('passes props down correctly to the ci table', () => { + createComponent(); + expect(findCiVariableTable().props()).toEqual({ + entity: 'project', isLoading: defaultProps.isLoading, + maxVariableLimit: defaultProps.maxVariableLimit, variables: defaultProps.variables, }); }); it('passes props down correctly to the ci modal', async () => { + createComponent(); + findCiVariableTable().vm.$emit('set-selected-variable'); await nextTick(); @@ -66,6 +71,10 @@ describe('Ci variable table', () => { }); describe('modal mode', () => { + beforeEach(() => { + createComponent(); + }); + it('passes down ADD mode when receiving an empty variable', async () => { findCiVariableTable().vm.$emit('set-selected-variable'); await nextTick(); @@ -82,6 +91,10 @@ describe('Ci variable table', () => { }); describe('variable modal', () => { + beforeEach(() => { + createComponent(); + }); + it('is hidden by default', () => { expect(findCiVariableModal().exists()).toBe(false); }); @@ -112,6 +125,10 @@ describe('Ci variable table', () => { }); describe('variable events', () => { + beforeEach(() => { + createComponent(); + }); + it.each` eventName ${'add-variable'} diff --git a/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js index 0cc0ee7a9c7..65a58a1647f 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_shared_spec.js @@ -29,6 +29,8 @@ import { createGroupProps, createInstanceProps, createProjectProps, + createGroupProvide, + createProjectProvide, devName, mockProjectEnvironments, mockProjectVariables, @@ -44,6 +46,8 @@ Vue.use(VueApollo); const mockProvide = { endpoint: '/variables', + isGroup: false, + isProject: false, }; const defaultProps = { @@ -68,6 +72,7 @@ describe('Ci Variable Shared Component', () => { customHandlers = null, isLoading = false, props = { ...createProjectProps() }, + provide = {}, } = {}) { const handlers = customHandlers || [ [getProjectEnvironments, mockEnvironments], @@ -81,7 +86,10 @@ describe('Ci Variable Shared Component', () => { ...defaultProps, ...props, }, - provide: mockProvide, + provide: { + ...mockProvide, + ...provide, + }, apolloProvider: mockApollo, stubs: { ciVariableSettings, ciVariableTable }, }); @@ -108,12 +116,18 @@ describe('Ci Variable Shared Component', () => { }); describe('when queries are resolved', () => { - describe('successfuly', () => { + describe('successfully', () => { beforeEach(async () => { mockEnvironments.mockResolvedValue(mockProjectEnvironments); mockVariables.mockResolvedValue(mockProjectVariables); - await createComponentWithApollo(); + await createComponentWithApollo({ provide: createProjectProvide() }); + }); + + it('passes down the expected max variable limit as props', () => { + expect(findCiSettings().props('maxVariableLimit')).toBe( + mockProjectVariables.data.project.ciVariables.limit, + ); }); it('passes down the expected environments as props', () => { @@ -285,23 +299,29 @@ describe('Ci Variable Shared Component', () => { }); describe('Props', () => { + const mockGroupCiVariables = mockGroupVariables.data.group.ciVariables; + const mockProjectCiVariables = mockProjectVariables.data.project.ciVariables; + describe('in a specific context as', () => { it.each` - name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | mutation - ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${null} - ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${getGroupVariables} - ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${getAdminVariables} + name | mockVariablesValue | mockEnvironmentsValue | withEnvironments | expectedEnvironments | propsFn | provideFn | mutation | maxVariableLimit + ${'project'} | ${mockProjectVariables} | ${mockProjectEnvironments} | ${true} | ${['prod', 'dev']} | ${createProjectProps} | ${createProjectProvide} | ${null} | ${mockProjectCiVariables.limit} + ${'group'} | ${mockGroupVariables} | ${[]} | ${false} | ${[]} | ${createGroupProps} | ${createGroupProvide} | ${getGroupVariables} | ${mockGroupCiVariables.limit} + ${'instance'} | ${mockAdminVariables} | ${[]} | ${false} | ${[]} | ${createInstanceProps} | ${() => {}} | ${getAdminVariables} | ${0} `( 'passes down all the required props when its a $name component', async ({ mutation, + maxVariableLimit, mockVariablesValue, mockEnvironmentsValue, withEnvironments, expectedEnvironments, propsFn, + provideFn, }) => { const props = propsFn(); + const provide = provideFn(); mockVariables.mockResolvedValue(mockVariablesValue); @@ -315,13 +335,15 @@ describe('Ci Variable Shared Component', () => { customHandlers = [[mutation, mockVariables]]; } - await createComponentWithApollo({ customHandlers, props }); + await createComponentWithApollo({ customHandlers, props, provide }); expect(findCiSettings().props()).toEqual({ areScopedVariablesAvailable: wrapper.props().areScopedVariablesAvailable, hideEnvironmentScope: defaultProps.hideEnvironmentScope, isLoading: false, + maxVariableLimit, variables: wrapper.props().queryData.ciVariables.lookup(mockVariablesValue.data)?.nodes, + entity: props.entity, environments: expectedEnvironments, }); }, diff --git a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js b/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js index 8a4c35173ec..9891bc397b6 100644 --- a/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js +++ b/spec/frontend/ci_variable_list/components/ci_variable_table_spec.js @@ -1,16 +1,22 @@ +import { GlAlert } from '@gitlab/ui'; +import { sprintf } from '~/locale'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import CiVariableTable from '~/ci_variable_list/components/ci_variable_table.vue'; -import { projectString } from '~/ci_variable_list/constants'; +import { EXCEEDS_VARIABLE_LIMIT_TEXT, projectString } from '~/ci_variable_list/constants'; import { mockVariables } from '../mocks'; describe('Ci variable table', () => { let wrapper; const defaultProps = { + entity: 'project', isLoading: false, + maxVariableLimit: mockVariables(projectString).length + 1, variables: mockVariables(projectString), }; + const mockMaxVariableLimit = defaultProps.variables.length; + const createComponent = ({ props = {} } = {}) => { wrapper = mountExtended(CiVariableTable, { attachTo: document.body, @@ -25,8 +31,15 @@ describe('Ci variable table', () => { const findAddButton = () => wrapper.findByLabelText('Add'); const findEditButton = () => wrapper.findByLabelText('Edit'); const findEmptyVariablesPlaceholder = () => wrapper.findByText('There are no variables yet.'); - const findHiddenValues = () => wrapper.findAll('[data-testid="hiddenValue"]'); - const findRevealedValues = () => wrapper.findAll('[data-testid="revealedValue"]'); + const findHiddenValues = () => wrapper.findAllByTestId('hiddenValue'); + const findLimitReachedAlerts = () => wrapper.findAllComponents(GlAlert); + const findRevealedValues = () => wrapper.findAllByTestId('revealedValue'); + const findOptionsValues = (rowIndex) => + wrapper.findAllByTestId('ci-variable-table-row-options').at(rowIndex).text(); + + const generateExceedsVariableLimitText = (entity, currentVariableCount, maxVariableLimit) => { + return sprintf(EXCEEDS_VARIABLE_LIMIT_TEXT, { entity, currentVariableCount, maxVariableLimit }); + }; beforeEach(() => { createComponent(); @@ -66,6 +79,67 @@ describe('Ci variable table', () => { it('displays the correct amount of variables', async () => { expect(wrapper.findAll('.js-ci-variable-row')).toHaveLength(defaultProps.variables.length); }); + + it('displays the correct variable options', async () => { + expect(findOptionsValues(0)).toBe('Protected, Expanded'); + expect(findOptionsValues(1)).toBe('Masked'); + }); + + it('enables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(false); + }); + }); + + describe('When variables have exceeded the max limit', () => { + beforeEach(() => { + createComponent({ props: { maxVariableLimit: mockVariables(projectString).length } }); + }); + + it('disables the Add Variable button', () => { + expect(findAddButton().props('disabled')).toBe(true); + }); + }); + + describe('max limit reached alert', () => { + describe('when there is no variable limit', () => { + beforeEach(() => { + createComponent({ + props: { maxVariableLimit: 0 }, + }); + }); + + it('hides alert', () => { + expect(findLimitReachedAlerts().length).toBe(0); + }); + }); + + describe('when variable limit exists', () => { + it('hides alert when limit has not been reached', () => { + createComponent(); + + expect(findLimitReachedAlerts().length).toBe(0); + }); + + it('shows alert when limit has been reached', () => { + const exceedsVariableLimitText = generateExceedsVariableLimitText( + defaultProps.entity, + defaultProps.variables.length, + mockMaxVariableLimit, + ); + + createComponent({ + props: { maxVariableLimit: mockMaxVariableLimit }, + }); + + expect(findLimitReachedAlerts().length).toBe(2); + + expect(findLimitReachedAlerts().at(0).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(0).text()).toContain(exceedsVariableLimitText); + + expect(findLimitReachedAlerts().at(1).props('dismissible')).toBe(false); + expect(findLimitReachedAlerts().at(1).text()).toContain(exceedsVariableLimitText); + }); + }); }); describe('Table click actions', () => { diff --git a/spec/frontend/ci_variable_list/mocks.js b/spec/frontend/ci_variable_list/mocks.js index 03b77f80430..065e9fa6667 100644 --- a/spec/frontend/ci_variable_list/mocks.js +++ b/spec/frontend/ci_variable_list/mocks.js @@ -34,6 +34,7 @@ export const mockVariables = (kind) => { key: 'my-var', masked: false, protected: true, + raw: false, value: 'variable_value', variableType: variableTypes.envType, }, @@ -43,6 +44,7 @@ export const mockVariables = (kind) => { key: 'secret', masked: true, protected: false, + raw: true, value: 'another_value', variableType: variableTypes.fileType, }, @@ -63,6 +65,7 @@ const createDefaultVars = ({ withScope = true, kind } = {}) => { return { __typename: `Ci${kind}VariableConnection`, + limit: 200, pageInfo: { startCursor: 'adsjsd12kldpsa', endCursor: 'adsjsd12kldpsa', @@ -140,6 +143,7 @@ export const newVariable = { export const createProjectProps = () => { return { componentName: 'ProjectVariable', + entity: 'project', fullPath: '/namespace/project/', id: 'gid://gitlab/Project/20', mutationData: { @@ -163,6 +167,7 @@ export const createProjectProps = () => { export const createGroupProps = () => { return { componentName: 'GroupVariable', + entity: 'group', fullPath: '/my-group', id: 'gid://gitlab/Group/20', mutationData: { @@ -182,6 +187,7 @@ export const createGroupProps = () => { export const createInstanceProps = () => { return { componentName: 'InstanceVariable', + entity: '', mutationData: { [ADD_MUTATION_ACTION]: addAdminVariable, [UPDATE_MUTATION_ACTION]: updateAdminVariable, @@ -195,3 +201,13 @@ export const createInstanceProps = () => { }, }; }; + +export const createGroupProvide = () => ({ + isGroup: true, + isProject: false, +}); + +export const createProjectProvide = () => ({ + isGroup: false, + isProject: true, +}); diff --git a/spec/frontend/clusters_list/components/agent_token_spec.js b/spec/frontend/clusters_list/components/agent_token_spec.js index 8d3130b45a6..a92a03fedb6 100644 --- a/spec/frontend/clusters_list/components/agent_token_spec.js +++ b/spec/frontend/clusters_list/components/agent_token_spec.js @@ -1,7 +1,13 @@ -import { GlAlert, GlFormInputGroup } from '@gitlab/ui'; +import { GlAlert, GlFormInputGroup, GlSprintf, GlLink, GlIcon } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { sprintf } from '~/locale'; import AgentToken from '~/clusters_list/components/agent_token.vue'; -import { I18N_AGENT_TOKEN, INSTALL_AGENT_MODAL_ID } from '~/clusters_list/constants'; +import { + I18N_AGENT_TOKEN, + INSTALL_AGENT_MODAL_ID, + NAME_MAX_LENGTH, + HELM_VERSION_POLICY_URL, +} from '~/clusters_list/constants'; import { generateAgentRegistrationCommand } from '~/clusters_list/clusters_util'; import CodeBlock from '~/vue_shared/components/code_block.vue'; import ModalCopyButton from '~/vue_shared/components/modal_copy_button.vue'; @@ -19,15 +25,17 @@ describe('InstallAgentModal', () => { const findCodeBlock = () => wrapper.findComponent(CodeBlock); const findCopyButton = () => wrapper.findComponent(ModalCopyButton); const findInput = () => wrapper.findComponent(GlFormInputGroup); + const findHelmVersionPolicyLink = () => wrapper.findComponent(GlLink); + const findHelmExternalLinkIcon = () => wrapper.findComponent(GlIcon); - const createWrapper = () => { + const createWrapper = (newAgentName = agentName) => { const provide = { kasAddress, kasVersion, }; const propsData = { - agentName, + agentName: newAgentName, agentToken, modalId, }; @@ -35,6 +43,9 @@ describe('InstallAgentModal', () => { wrapper = shallowMountExtended(AgentToken, { provide, propsData, + stubs: { + GlSprintf, + }, }); }; @@ -52,6 +63,17 @@ describe('InstallAgentModal', () => { expect(wrapper.text()).toContain(I18N_AGENT_TOKEN.basicInstallBody); }); + it('shows Helm version policy text with an external link', () => { + expect(wrapper.text()).toContain( + sprintf(I18N_AGENT_TOKEN.helmVersionText, { linkStart: '', linkEnd: ' ' }), + ); + expect(findHelmVersionPolicyLink().attributes()).toMatchObject({ + href: HELM_VERSION_POLICY_URL, + target: '_blank', + }); + expect(findHelmExternalLinkIcon().props()).toMatchObject({ name: 'external-link', size: 12 }); + }); + it('shows advanced agent installation instructions', () => { expect(wrapper.text()).toContain(I18N_AGENT_TOKEN.advancedInstallTitle); }); @@ -79,9 +101,19 @@ describe('InstallAgentModal', () => { it('shows code block with agent installation command', () => { expect(findCodeBlock().props('code')).toContain(`helm upgrade --install ${agentName}`); + expect(findCodeBlock().props('code')).toContain(`--namespace gitlab-agent-${agentName}`); expect(findCodeBlock().props('code')).toContain(`--set config.token=${agentToken}`); expect(findCodeBlock().props('code')).toContain(`--set config.kasAddress=${kasAddress}`); expect(findCodeBlock().props('code')).toContain(`--set image.tag=v${kasVersion}`); }); + + it('truncates the namespace name if it exceeds the maximum length', () => { + const newAgentName = 'agent-name-that-is-too-long-and-needs-to-be-truncated-to-use'; + createWrapper(newAgentName); + + expect(findCodeBlock().props('code')).toContain( + `--namespace gitlab-agent-${newAgentName.substring(0, NAME_MAX_LENGTH)}`, + ); + }); }); }); diff --git a/spec/frontend/clusters_list/components/agents_spec.js b/spec/frontend/clusters_list/components/agents_spec.js index bff1e573dbd..2372ab30300 100644 --- a/spec/frontend/clusters_list/components/agents_spec.js +++ b/spec/frontend/clusters_list/components/agents_spec.js @@ -1,7 +1,7 @@ import { GlAlert, GlKeysetPagination, GlLoadingIcon, GlBanner } from '@gitlab/ui'; -import { createLocalVue, shallowMount } from '@vue/test-utils'; +import { shallowMount } from '@vue/test-utils'; import VueApollo from 'vue-apollo'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import AgentEmptyState from '~/clusters_list/components/agent_empty_state.vue'; import AgentTable from '~/clusters_list/components/agent_table.vue'; import Agents from '~/clusters_list/components/agents.vue'; @@ -12,10 +12,10 @@ import { } from '~/clusters_list/constants'; import getAgentsQuery from '~/clusters_list/graphql/queries/get_agents.query.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -const localVue = createLocalVue(); -localVue.use(VueApollo); +Vue.use(VueApollo); describe('Agents', () => { let wrapper; @@ -34,9 +34,10 @@ describe('Agents', () => { pageInfo = null, trees = [], count = 0, + queryResponse = null, }) => { const provide = provideData; - const apolloQueryResponse = { + const queryResponseData = { data: { project: { id: '1', @@ -51,13 +52,12 @@ describe('Agents', () => { }, }, }; + const agentQueryResponse = + queryResponse || jest.fn().mockResolvedValue(queryResponseData, provide); - const apolloProvider = createMockApollo([ - [getAgentsQuery, jest.fn().mockResolvedValue(apolloQueryResponse, provide)], - ]); + const apolloProvider = createMockApollo([[getAgentsQuery, agentQueryResponse]]); wrapper = shallowMount(Agents, { - localVue, apolloProvider, propsData: { ...defaultProps, @@ -313,24 +313,11 @@ describe('Agents', () => { }); describe('when agents query is loading', () => { - const mocks = { - $apollo: { - queries: { - agents: { - loading: true, - }, - }, - }, - }; - - beforeEach(async () => { - wrapper = shallowMount(Agents, { - mocks, - propsData: defaultProps, - provide: provideData, + beforeEach(() => { + createWrapper({ + queryResponse: jest.fn().mockReturnValue(new Promise(() => {})), }); - - await nextTick(); + return waitForPromises(); }); it('displays a loading icon', () => { diff --git a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js index 197735d3c77..02b455d0b61 100644 --- a/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js +++ b/spec/frontend/clusters_list/components/available_agents_dropwdown_spec.js @@ -1,34 +1,31 @@ -import { GlDropdown, GlDropdownItem, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox, GlButton } from '@gitlab/ui'; +import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { ENTER_KEY } from '~/lib/utils/keys'; import AvailableAgentsDropdown from '~/clusters_list/components/available_agents_dropdown.vue'; import { I18N_AVAILABLE_AGENTS_DROPDOWN } from '~/clusters_list/constants'; describe('AvailableAgentsDropdown', () => { let wrapper; + const configuredAgent = 'configured-agent'; + const searchAgentName = 'search-agent'; + const newAgentName = 'new-agent'; + const i18n = I18N_AVAILABLE_AGENTS_DROPDOWN; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstAgentItem = () => findDropdownItems().at(0); - const findSearchInput = () => wrapper.findComponent(GlSearchBoxByType); - const findCreateButton = () => wrapper.findByTestId('create-config-button'); + const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + const findCreateButton = () => wrapper.findComponent(GlButton); const createWrapper = ({ propsData }) => { wrapper = shallowMountExtended(AvailableAgentsDropdown, { propsData, - stubs: { GlDropdown }, + stubs: { GlCollapsibleListbox }, }); - wrapper.vm.$refs.dropdown.hide = jest.fn(); + wrapper.vm.$refs.dropdown.closeAndFocus = jest.fn(); }; - afterEach(() => { - wrapper.destroy(); - }); - describe('there are agents available', () => { const propsData = { - availableAgents: ['configured-agent', 'search-agent', 'test-agent'], + availableAgents: [configuredAgent, searchAgentName, 'test-agent'], isRegistering: false, }; @@ -37,91 +34,93 @@ describe('AvailableAgentsDropdown', () => { }); it('prompts to select an agent', () => { - expect(findDropdown().props('text')).toBe(i18n.selectAgent); + expect(findDropdown().props('toggleText')).toBe(i18n.selectAgent); }); describe('search agent', () => { it('renders search button', () => { - expect(findSearchInput().exists()).toBe(true); + expect(findDropdown().props('searchable')).toBe(true); }); it('renders all agents when search term is empty', () => { - expect(findDropdownItems()).toHaveLength(3); + expect(findDropdown().props('items')).toHaveLength(3); }); it('renders only the agent searched for when the search item exists', async () => { - await findSearchInput().vm.$emit('input', 'search-agent'); - - expect(findDropdownItems()).toHaveLength(1); - expect(findFirstAgentItem().text()).toBe('search-agent'); - }); + findDropdown().vm.$emit('search', searchAgentName); + await nextTick(); - it('renders create button when search started', async () => { - await findSearchInput().vm.$emit('input', 'new-agent'); - - expect(findCreateButton().exists()).toBe(true); + expect(findDropdown().props('items')).toMatchObject([ + { text: searchAgentName, value: searchAgentName }, + ]); }); - it("doesn't render create button when search item is found", async () => { - await findSearchInput().vm.$emit('input', 'search-agent'); - - expect(findCreateButton().exists()).toBe(false); + describe('create button', () => { + it.each` + condition | search | createButtonRendered + ${'is rendered'} | ${newAgentName} | ${true} + ${'is not rendered'} | ${''} | ${false} + ${'is not rendered'} | ${searchAgentName} | ${false} + `('$condition when search is "$search"', async ({ search, createButtonRendered }) => { + findDropdown().vm.$emit('search', search); + await nextTick(); + + expect(findCreateButton().exists()).toBe(createButtonRendered); + }); }); }); describe('select existing agent configuration', () => { beforeEach(() => { - findFirstAgentItem().vm.$emit('click'); + findDropdown().vm.$emit('select', configuredAgent); }); - it('emits agentSelected with the name of the clicked agent', () => { - expect(wrapper.emitted('agentSelected')).toEqual([['configured-agent']]); + it('emits `agentSelected` with the name of the clicked agent', () => { + expect(wrapper.emitted('agentSelected')).toEqual([[configuredAgent]]); }); it('marks the clicked item as selected', () => { - expect(findDropdown().props('text')).toBe('configured-agent'); - expect(findFirstAgentItem().props('isChecked')).toBe(true); + expect(findDropdown().props('toggleText')).toBe(configuredAgent); }); }); describe('create new agent configuration', () => { beforeEach(async () => { - await findSearchInput().vm.$emit('input', 'new-agent'); + findDropdown().vm.$emit('search', newAgentName); + await nextTick(); findCreateButton().vm.$emit('click'); }); it('emits agentSelected with the name of the clicked agent', () => { - expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]); + expect(wrapper.emitted('agentSelected')).toEqual([[newAgentName]]); }); it('marks the clicked item as selected', () => { - expect(findDropdown().props('text')).toBe('new-agent'); + expect(findDropdown().props('toggleText')).toBe(newAgentName); }); }); describe('click enter to register new agent without configuration', () => { beforeEach(async () => { - await findSearchInput().vm.$emit('input', 'new-agent'); - await findSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: ENTER_KEY })); + const dropdown = findDropdown(); + dropdown.vm.$emit('search', newAgentName); + await nextTick(); + await dropdown.trigger('keydown.enter'); }); it('emits agentSelected with the name of the clicked agent', () => { - expect(wrapper.emitted('agentSelected')).toEqual([['new-agent']]); + expect(wrapper.emitted('agentSelected')).toEqual([[newAgentName]]); }); it('marks the clicked item as selected', () => { - expect(findDropdown().props('text')).toBe('new-agent'); - }); - - it('closes the dropdown', () => { - expect(wrapper.vm.$refs.dropdown.hide).toHaveBeenCalledTimes(1); + expect(findDropdown().props('toggleText')).toBe(newAgentName); }); }); }); describe('registration in progress', () => { const propsData = { - availableAgents: ['configured-agent'], + availableAgents: [configuredAgent], isRegistering: true, }; @@ -130,7 +129,7 @@ describe('AvailableAgentsDropdown', () => { }); it('updates the text in the dropdown', () => { - expect(findDropdown().props('text')).toBe(i18n.registeringAgent); + expect(findDropdown().props('toggleText')).toBe(i18n.registeringAgent); }); it('displays a loading icon', () => { diff --git a/spec/frontend/clusters_list/components/clusters_spec.js b/spec/frontend/clusters_list/components/clusters_spec.js index a3f42c1f161..e8e705a6384 100644 --- a/spec/frontend/clusters_list/components/clusters_spec.js +++ b/spec/frontend/clusters_list/components/clusters_spec.js @@ -61,6 +61,10 @@ describe('Clusters', () => { let captureException; beforeEach(() => { + jest.spyOn(Sentry, 'withScope').mockImplementation((fn) => { + const mockScope = { setTag: () => {} }; + fn(mockScope); + }); captureException = jest.spyOn(Sentry, 'captureException'); mock = new MockAdapter(axios); diff --git a/spec/frontend/clusters_list/store/actions_spec.js b/spec/frontend/clusters_list/store/actions_spec.js index 09b1f80ff9b..1deebf8b75a 100644 --- a/spec/frontend/clusters_list/store/actions_spec.js +++ b/spec/frontend/clusters_list/store/actions_spec.js @@ -17,6 +17,10 @@ describe('Clusters store actions', () => { describe('reportSentryError', () => { beforeEach(() => { + jest.spyOn(Sentry, 'withScope').mockImplementation((fn) => { + const mockScope = { setTag: () => {} }; + fn(mockScope); + }); captureException = jest.spyOn(Sentry, 'captureException'); }); diff --git a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap index 6ad8a9de8d3..331a0a474a3 100644 --- a/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap +++ b/spec/frontend/content_editor/components/__snapshots__/toolbar_link_button_spec.js.snap @@ -14,15 +14,15 @@ exports[`content_editor/components/toolbar_link_button renders dropdown componen </div> </form> </li> - <li role=\\"presentation\\" class=\\"gl-new-dropdown-divider\\"> + <li role=\\"presentation\\" class=\\"gl-dropdown-divider\\"> <hr role=\\"separator\\" aria-orientation=\\"horizontal\\" class=\\"dropdown-divider\\"> </li> - <li role=\\"presentation\\" class=\\"gl-new-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\"> + <li role=\\"presentation\\" class=\\"gl-dropdown-item\\"><button role=\\"menuitem\\" type=\\"button\\" class=\\"dropdown-item\\"> <!----> <!----> <!----> - <div class=\\"gl-new-dropdown-item-text-wrapper\\"> - <p class=\\"gl-new-dropdown-item-text-primary\\"> + <div class=\\"gl-dropdown-item-text-wrapper\\"> + <p class=\\"gl-dropdown-item-text-primary\\"> Upload file </p> <!----> diff --git a/spec/frontend/content_editor/components/content_editor_spec.js b/spec/frontend/content_editor/components/content_editor_spec.js index c1c2a125515..1a3cd36a8bb 100644 --- a/spec/frontend/content_editor/components/content_editor_spec.js +++ b/spec/frontend/content_editor/components/content_editor_spec.js @@ -10,7 +10,7 @@ import FormattingBubbleMenu from '~/content_editor/components/bubble_menus/forma import CodeBlockBubbleMenu from '~/content_editor/components/bubble_menus/code_block_bubble_menu.vue'; import LinkBubbleMenu from '~/content_editor/components/bubble_menus/link_bubble_menu.vue'; import MediaBubbleMenu from '~/content_editor/components/bubble_menus/media_bubble_menu.vue'; -import TopToolbar from '~/content_editor/components/top_toolbar.vue'; +import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue'; import LoadingIndicator from '~/content_editor/components/loading_indicator.vue'; import waitForPromises from 'helpers/wait_for_promises'; import { KEYDOWN_EVENT } from '~/content_editor/constants'; @@ -27,13 +27,14 @@ describe('ContentEditor', () => { const findEditorStateObserver = () => wrapper.findComponent(EditorStateObserver); const findLoadingIndicator = () => wrapper.findComponent(LoadingIndicator); const findContentEditorAlert = () => wrapper.findComponent(ContentEditorAlert); - const createWrapper = ({ markdown, autofocus } = {}) => { + const createWrapper = ({ markdown, autofocus, useBottomToolbar } = {}) => { wrapper = shallowMountExtended(ContentEditor, { propsData: { renderMarkdown, uploadsPath, markdown, autofocus, + useBottomToolbar, }, stubs: { EditorStateObserver, @@ -89,7 +90,19 @@ describe('ContentEditor', () => { it('renders top toolbar component', () => { createWrapper(); - expect(wrapper.findComponent(TopToolbar).exists()).toBe(true); + expect(wrapper.findComponent(FormattingToolbar).exists()).toBe(true); + expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-t')).toBe(false); + expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-b')).toBe(true); + }); + + it('renders bottom toolbar component', () => { + createWrapper({ + useBottomToolbar: true, + }); + + expect(wrapper.findComponent(FormattingToolbar).exists()).toBe(true); + expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-t')).toBe(true); + expect(wrapper.findComponent(FormattingToolbar).classes('gl-border-b')).toBe(false); }); describe('when setting initial content', () => { diff --git a/spec/frontend/content_editor/components/top_toolbar_spec.js b/spec/frontend/content_editor/components/formatting_toolbar_spec.js index 8f194ff32e2..c4bf21ba813 100644 --- a/spec/frontend/content_editor/components/top_toolbar_spec.js +++ b/spec/frontend/content_editor/components/formatting_toolbar_spec.js @@ -1,6 +1,6 @@ import { mockTracking } from 'helpers/tracking_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import TopToolbar from '~/content_editor/components/top_toolbar.vue'; +import FormattingToolbar from '~/content_editor/components/formatting_toolbar.vue'; import { TOOLBAR_CONTROL_TRACKING_ACTION, CONTENT_EDITOR_TRACKING_LABEL, @@ -11,7 +11,7 @@ describe('content_editor/components/top_toolbar', () => { let trackingSpy; const buildWrapper = () => { - wrapper = shallowMountExtended(TopToolbar); + wrapper = shallowMountExtended(FormattingToolbar); }; beforeEach(() => { diff --git a/spec/frontend/content_editor/extensions/comment_spec.js b/spec/frontend/content_editor/extensions/comment_spec.js new file mode 100644 index 00000000000..7d8ff28e4d7 --- /dev/null +++ b/spec/frontend/content_editor/extensions/comment_spec.js @@ -0,0 +1,30 @@ +import Comment from '~/content_editor/extensions/comment'; +import { createTestEditor, createDocBuilder, triggerNodeInputRule } from '../test_utils'; + +describe('content_editor/extensions/comment', () => { + let tiptapEditor; + let doc; + let comment; + + beforeEach(() => { + tiptapEditor = createTestEditor({ extensions: [Comment] }); + ({ + builders: { doc, comment }, + } = createDocBuilder({ + tiptapEditor, + names: { + comment: { nodeType: Comment.name }, + }, + })); + }); + + describe('when typing the comment input rule', () => { + it('inserts a comment node', () => { + const expectedDoc = doc(comment()); + + triggerNodeInputRule({ tiptapEditor, inputRuleText: '<!-- ' }); + + expect(tiptapEditor.getJSON()).toEqual(expectedDoc.toJSON()); + }); + }); +}); diff --git a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js index 5458a42532f..90d83820c70 100644 --- a/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js +++ b/spec/frontend/content_editor/services/gl_api_markdown_deserializer_spec.js @@ -1,5 +1,6 @@ import createMarkdownDeserializer from '~/content_editor/services/gl_api_markdown_deserializer'; import Bold from '~/content_editor/extensions/bold'; +import Comment from '~/content_editor/extensions/comment'; import { createTestEditor, createDocBuilder } from '../test_utils'; describe('content_editor/services/gl_api_markdown_deserializer', () => { @@ -7,19 +8,21 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { let doc; let p; let bold; + let comment; let tiptapEditor; beforeEach(() => { tiptapEditor = createTestEditor({ - extensions: [Bold], + extensions: [Bold, Comment], }); ({ - builders: { doc, p, bold }, + builders: { doc, p, bold, comment }, } = createDocBuilder({ tiptapEditor, names: { bold: { markType: Bold.name }, + comment: { nodeType: Comment.name }, }, })); renderMarkdown = jest.fn(); @@ -33,7 +36,7 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { const deserializer = createMarkdownDeserializer({ render: renderMarkdown }); renderMarkdown.mockResolvedValueOnce( - `<p><strong>${text}</strong></p><pre lang="javascript"></pre>`, + `<p><strong>${text}</strong></p><pre lang="javascript"></pre><!-- some comment -->`, ); result = await deserializer.deserialize({ @@ -41,8 +44,9 @@ describe('content_editor/services/gl_api_markdown_deserializer', () => { schema: tiptapEditor.schema, }); }); + it('transforms HTML returned by render function to a ProseMirror document', async () => { - const document = doc(p(bold(text))); + const document = doc(p(bold(text)), comment(' some comment ')); expect(result.document.toJSON()).toEqual(document.toJSON()); }); diff --git a/spec/frontend/content_editor/services/markdown_serializer_spec.js b/spec/frontend/content_editor/services/markdown_serializer_spec.js index 1bf23415052..2cd8b8a0d6f 100644 --- a/spec/frontend/content_editor/services/markdown_serializer_spec.js +++ b/spec/frontend/content_editor/services/markdown_serializer_spec.js @@ -3,6 +3,7 @@ import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Comment from '~/content_editor/extensions/comment'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; @@ -50,6 +51,7 @@ const { bulletList, code, codeBlock, + comment, details, detailsContent, div, @@ -89,6 +91,7 @@ const { bulletList: { nodeType: BulletList.name }, code: { markType: Code.name }, codeBlock: { nodeType: CodeBlockHighlight.name }, + comment: { nodeType: Comment.name }, details: { nodeType: Details.name }, detailsContent: { nodeType: DetailsContent.name }, descriptionItem: { nodeType: DescriptionItem.name }, @@ -169,6 +172,17 @@ describe('markdownSerializer', () => { ); }); + it('correctly serializes a comment node', () => { + expect(serialize(paragraph('hi'), comment(' this is a\ncomment '))).toBe( + ` +hi + +<!-- this is a +comment --> + `.trim(), + ); + }); + it('correctly serializes a line break', () => { expect(serialize(paragraph('hello', hardBreak(), 'world'))).toBe('hello\\\nworld'); }); @@ -304,7 +318,7 @@ var y = 10; expect( serialize( codeBlock( - { language: 'json' }, + { language: 'json', langParams: '' }, 'this is not really json but just trying out whether this case works or not', ), ), @@ -317,6 +331,23 @@ this is not really json but just trying out whether this case works or not ); }); + it('correctly serializes a code block with language parameters', () => { + expect( + serialize( + codeBlock( + { language: 'json', langParams: 'table' }, + 'this is not really json:table but just trying out whether this case works or not', + ), + ), + ).toBe( + ` +\`\`\`json:table +this is not really json:table but just trying out whether this case works or not +\`\`\` + `.trim(), + ); + }); + it('correctly serializes emoji', () => { expect(serialize(paragraph(emoji({ name: 'dog' })))).toBe(':dog:'); }); @@ -366,6 +397,26 @@ this is not really json but just trying out whether this case works or not ); }); + it.each` + width | height | outputAttributes + ${300} | ${undefined} | ${'width=300'} + ${undefined} | ${300} | ${'height=300'} + ${300} | ${300} | ${'width=300 height=300'} + ${'300%'} | ${'300px'} | ${'width="300%" height="300px"'} + `( + 'correctly serializes an image with width and height attributes', + ({ width, height, outputAttributes }) => { + const imageAttrs = { src: 'img.jpg', alt: 'foo bar' }; + + if (width) imageAttrs.width = width; + if (height) imageAttrs.height = height; + + expect(serialize(paragraph(image(imageAttrs)))).toBe( + `![foo bar](img.jpg){${outputAttributes}}`, + ); + }, + ); + it('does not serialize an image when src and canonicalSrc are empty', () => { expect(serialize(paragraph(image({})))).toBe(''); }); diff --git a/spec/frontend/content_editor/test_utils.js b/spec/frontend/content_editor/test_utils.js index 0768fa6e8df..0fa0e65cd26 100644 --- a/spec/frontend/content_editor/test_utils.js +++ b/spec/frontend/content_editor/test_utils.js @@ -11,10 +11,12 @@ import Bold from '~/content_editor/extensions/bold'; import BulletList from '~/content_editor/extensions/bullet_list'; import Code from '~/content_editor/extensions/code'; import CodeBlockHighlight from '~/content_editor/extensions/code_block_highlight'; +import Comment from '~/content_editor/extensions/comment'; import DescriptionItem from '~/content_editor/extensions/description_item'; import DescriptionList from '~/content_editor/extensions/description_list'; import Details from '~/content_editor/extensions/details'; import DetailsContent from '~/content_editor/extensions/details_content'; +import Diagram from '~/content_editor/extensions/diagram'; import Emoji from '~/content_editor/extensions/emoji'; import FootnoteDefinition from '~/content_editor/extensions/footnote_definition'; import FootnoteReference from '~/content_editor/extensions/footnote_reference'; @@ -211,10 +213,12 @@ export const createTiptapEditor = (extensions = []) => BulletList, Code, CodeBlockHighlight, + Comment, DescriptionItem, DescriptionList, Details, DetailsContent, + Diagram, Emoji, FootnoteDefinition, FootnoteReference, diff --git a/spec/frontend/crm/contact_form_wrapper_spec.js b/spec/frontend/crm/contact_form_wrapper_spec.js index e49b553e4b5..50b432943fb 100644 --- a/spec/frontend/crm/contact_form_wrapper_spec.js +++ b/spec/frontend/crm/contact_form_wrapper_spec.js @@ -4,7 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import createMockApollo from 'helpers/mock_apollo_helper'; import ContactFormWrapper from '~/crm/contacts/components/contact_form_wrapper.vue'; -import ContactForm from '~/crm/components/form.vue'; +import CrmForm from '~/crm/components/crm_form.vue'; import getGroupContactsQuery from '~/crm/contacts/components/graphql/get_group_contacts.query.graphql'; import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; @@ -16,7 +16,7 @@ describe('Customer relations contact form wrapper', () => { let wrapper; let fakeApollo; - const findContactForm = () => wrapper.findComponent(ContactForm); + const findCrmForm = () => wrapper.findComponent(CrmForm); const $route = { params: { @@ -65,21 +65,21 @@ describe('Customer relations contact form wrapper', () => { }); it('renders correct getQuery prop', () => { - expect(findContactForm().props('getQueryNodePath')).toBe('group.contacts'); + expect(findCrmForm().props('getQueryNodePath')).toBe('group.contacts'); }); it('renders correct mutation prop', () => { - expect(findContactForm().props('mutation')).toBe(mutation); + expect(findCrmForm().props('mutation')).toBe(mutation); }); it('renders correct additionalCreateParams prop', () => { - expect(findContactForm().props('additionalCreateParams')).toMatchObject({ + expect(findCrmForm().props('additionalCreateParams')).toMatchObject({ groupId: 'gid://gitlab/Group/26', }); }); it('renders correct existingId prop', () => { - expect(findContactForm().props('existingId')).toBe(existingId); + expect(findCrmForm().props('existingId')).toBe(existingId); }); it('renders correct fields prop', () => { @@ -101,15 +101,15 @@ describe('Customer relations contact form wrapper', () => { { name: 'description', label: 'Description' }, ]; if (isEditMode) fields.push({ name: 'active', label: 'Active', required: true, bool: true }); - expect(findContactForm().props('fields')).toEqual(fields); + expect(findCrmForm().props('fields')).toEqual(fields); }); it('renders correct title prop', () => { - expect(findContactForm().props('title')).toBe(title); + expect(findCrmForm().props('title')).toBe(title); }); it('renders correct successMessage prop', () => { - expect(findContactForm().props('successMessage')).toBe(successMessage); + expect(findCrmForm().props('successMessage')).toBe(successMessage); }); }); }); diff --git a/spec/frontend/crm/form_spec.js b/spec/frontend/crm/crm_form_spec.js index 57e28b396cf..eabcf5b1b1b 100644 --- a/spec/frontend/crm/form_spec.js +++ b/spec/frontend/crm/crm_form_spec.js @@ -5,7 +5,7 @@ import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import Form from '~/crm/components/form.vue'; +import CrmForm from '~/crm/components/crm_form.vue'; import routes from '~/crm/contacts/routes'; import createContactMutation from '~/crm/contacts/components/graphql/create_contact.mutation.graphql'; import updateContactMutation from '~/crm/contacts/components/graphql/update_contact.mutation.graphql'; @@ -81,7 +81,7 @@ describe('Reusable form component', () => { const findFormGroup = (at) => wrapper.findAllComponents(GlFormGroup).at(at); const mountComponent = (propsData) => { - wrapper = shallowMountExtended(Form, { + wrapper = shallowMountExtended(CrmForm, { router, apolloProvider: fakeApollo, propsData: { drawerOpen: true, ...propsData }, diff --git a/spec/frontend/crm/organization_form_wrapper_spec.js b/spec/frontend/crm/organization_form_wrapper_spec.js index 9f26b9157e6..d795c585622 100644 --- a/spec/frontend/crm/organization_form_wrapper_spec.js +++ b/spec/frontend/crm/organization_form_wrapper_spec.js @@ -1,6 +1,6 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import OrganizationFormWrapper from '~/crm/organizations/components/organization_form_wrapper.vue'; -import OrganizationForm from '~/crm/components/form.vue'; +import CrmForm from '~/crm/components/crm_form.vue'; import getGroupOrganizationsQuery from '~/crm/organizations/components/graphql/get_group_organizations.query.graphql'; import createOrganizationMutation from '~/crm/organizations/components/graphql/create_organization.mutation.graphql'; import updateOrganizationMutation from '~/crm/organizations/components/graphql/update_organization.mutation.graphql'; @@ -8,7 +8,7 @@ import updateOrganizationMutation from '~/crm/organizations/components/graphql/u describe('Customer relations organization form wrapper', () => { let wrapper; - const findOrganizationForm = () => wrapper.findComponent(OrganizationForm); + const findOrganizationForm = () => wrapper.findComponent(CrmForm); const $apollo = { queries: { diff --git a/spec/frontend/deploy_freeze/store/actions_spec.js b/spec/frontend/deploy_freeze/store/actions_spec.js index ce0c924bed2..9b96ce5d252 100644 --- a/spec/frontend/deploy_freeze/store/actions_spec.js +++ b/spec/frontend/deploy_freeze/store/actions_spec.js @@ -4,7 +4,7 @@ import Api from '~/api'; import * as actions from '~/deploy_freeze/store/actions'; import * as types from '~/deploy_freeze/store/mutation_types'; import getInitialState from '~/deploy_freeze/store/state'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import * as logger from '~/lib/logger'; import axios from '~/lib/utils/axios_utils'; import { freezePeriodsFixture } from '../helpers'; @@ -99,8 +99,8 @@ describe('deploy freeze store actions', () => { }); describe('addFreezePeriod', () => { - it('dispatch correct actions on adding a freeze period', () => { - testAction( + it('dispatch correct actions on adding a freeze period', async () => { + await testAction( actions.addFreezePeriod, {}, state, @@ -110,32 +110,33 @@ describe('deploy freeze store actions', () => { { type: 'receiveFreezePeriodSuccess' }, { type: 'fetchFreezePeriods' }, ], - () => - expect(Api.createFreezePeriod).toHaveBeenCalledWith(state.projectId, { - freeze_start: state.freezeStartCron, - freeze_end: state.freezeEndCron, - cron_timezone: state.selectedTimezoneIdentifier, - }), ); + + expect(Api.createFreezePeriod).toHaveBeenCalledWith(state.projectId, { + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }); }); - it('should show flash error and set error in state on add failure', () => { + it('should show alert and set error in state on add failure', async () => { Api.createFreezePeriod.mockRejectedValue(); - testAction( + await testAction( actions.addFreezePeriod, {}, state, [], [{ type: 'requestFreezePeriod' }, { type: 'receiveFreezePeriodError' }], - () => expect(createFlash).toHaveBeenCalled(), ); + + expect(createAlert).toHaveBeenCalled(); }); }); describe('updateFreezePeriod', () => { - it('dispatch correct actions on updating a freeze period', () => { - testAction( + it('dispatch correct actions on updating a freeze period', async () => { + await testAction( actions.updateFreezePeriod, {}, state, @@ -145,33 +146,34 @@ describe('deploy freeze store actions', () => { { type: 'receiveFreezePeriodSuccess' }, { type: 'fetchFreezePeriods' }, ], - () => - expect(Api.updateFreezePeriod).toHaveBeenCalledWith(state.projectId, { - id: state.selectedId, - freeze_start: state.freezeStartCron, - freeze_end: state.freezeEndCron, - cron_timezone: state.selectedTimezoneIdentifier, - }), ); + + expect(Api.updateFreezePeriod).toHaveBeenCalledWith(state.projectId, { + id: state.selectedId, + freeze_start: state.freezeStartCron, + freeze_end: state.freezeEndCron, + cron_timezone: state.selectedTimezoneIdentifier, + }); }); - it('should show flash error and set error in state on add failure', () => { + it('should show alert and set error in state on add failure', async () => { Api.updateFreezePeriod.mockRejectedValue(); - testAction( + await testAction( actions.updateFreezePeriod, {}, state, [], [{ type: 'requestFreezePeriod' }, { type: 'receiveFreezePeriodError' }], - () => expect(createFlash).toHaveBeenCalled(), ); + + expect(createAlert).toHaveBeenCalled(); }); }); describe('fetchFreezePeriods', () => { it('dispatch correct actions on fetchFreezePeriods', () => { - testAction( + return testAction( actions.fetchFreezePeriods, {}, state, @@ -183,26 +185,26 @@ describe('deploy freeze store actions', () => { ); }); - it('should show flash error and set error in state on fetch variables failure', () => { + it('should show alert and set error in state on fetch variables failure', async () => { Api.freezePeriods.mockRejectedValue(); - testAction( + await testAction( actions.fetchFreezePeriods, {}, state, [{ type: types.REQUEST_FREEZE_PERIODS }], [], - () => - expect(createFlash).toHaveBeenCalledWith({ - message: 'There was an error fetching the deploy freezes.', - }), ); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was an error fetching the deploy freezes.', + }); }); }); describe('deleteFreezePeriod', () => { - it('dispatch correct actions on deleting a freeze period', () => { - testAction( + it('dispatch correct actions on deleting a freeze period', async () => { + await testAction( actions.deleteFreezePeriod, freezePeriodFixture, state, @@ -211,20 +213,17 @@ describe('deploy freeze store actions', () => { { type: 'RECEIVE_DELETE_FREEZE_PERIOD_SUCCESS', payload: freezePeriodFixture.id }, ], [], - () => - expect(Api.deleteFreezePeriod).toHaveBeenCalledWith( - state.projectId, - freezePeriodFixture.id, - ), ); + + expect(Api.deleteFreezePeriod).toHaveBeenCalledWith(state.projectId, freezePeriodFixture.id); }); - it('should show flash error and set error in state on delete failure', () => { + it('should show alert and set error in state on delete failure', async () => { jest.spyOn(logger, 'logError').mockImplementation(); const error = new Error(); Api.deleteFreezePeriod.mockRejectedValue(error); - testAction( + await testAction( actions.deleteFreezePeriod, freezePeriodFixture, state, @@ -233,12 +232,11 @@ describe('deploy freeze store actions', () => { { type: 'RECEIVE_DELETE_FREEZE_PERIOD_ERROR', payload: freezePeriodFixture.id }, ], [], - () => { - expect(createFlash).toHaveBeenCalled(); - - expect(logger.logError).toHaveBeenCalledWith('Unable to delete deploy freeze', error); - }, ); + + expect(createAlert).toHaveBeenCalled(); + + expect(logger.logError).toHaveBeenCalledWith('Unable to delete deploy freeze', error); }); }); }); diff --git a/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js b/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js index 990f18d64c1..0bf69acd251 100644 --- a/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js +++ b/spec/frontend/deploy_tokens/components/new_deploy_token_spec.js @@ -6,9 +6,21 @@ import axios from '~/lib/utils/axios_utils'; import { TEST_HOST } from 'helpers/test_constants'; import NewDeployToken from '~/deploy_tokens/components/new_deploy_token.vue'; import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert, VARIANT_INFO } from '~/flash'; const createNewTokenPath = `${TEST_HOST}/create`; const deployTokensHelpUrl = `${TEST_HOST}/help`; + +jest.mock('~/flash', () => { + const original = jest.requireActual('~/flash'); + + return { + __esModule: true, + ...original, + createAlert: jest.fn(), + }; +}); + describe('New Deploy Token', () => { let wrapper; @@ -69,9 +81,69 @@ describe('New Deploy Token', () => { expect(tokenUsername.props('value')).toBe('test token username'); expect(tokenValue.props('value')).toBe('test token'); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + variant: VARIANT_INFO, + }), + ); }); } + it('should flash error message if token creation fails', async () => { + const mockAxios = new MockAdapter(axios); + + const date = new Date(); + const formInputs = wrapper.findAllComponents(GlFormInput); + const name = formInputs.at(0); + const username = formInputs.at(2); + name.vm.$emit('input', 'test name'); + username.vm.$emit('input', 'test username'); + + const datepicker = wrapper.findAllComponents(GlDatepicker).at(0); + datepicker.vm.$emit('input', date); + + const [ + readRepo, + readRegistry, + writeRegistry, + readPackageRegistry, + writePackageRegistry, + ] = wrapper.findAllComponents(GlFormCheckbox).wrappers; + readRepo.vm.$emit('input', true); + readRegistry.vm.$emit('input', true); + writeRegistry.vm.$emit('input', true); + readPackageRegistry.vm.$emit('input', true); + writePackageRegistry.vm.$emit('input', true); + + const expectedErrorMessage = 'Server error while creating a token'; + + mockAxios + .onPost(createNewTokenPath, { + deploy_token: { + name: 'test name', + expires_at: date.toISOString(), + username: 'test username', + read_repository: true, + read_registry: true, + write_registry: true, + read_package_registry: true, + write_package_registry: true, + }, + }) + .replyOnce(500, { message: expectedErrorMessage }); + + wrapper.findAllComponents(GlButton).at(0).vm.$emit('click'); + + await waitForPromises().then(() => nextTick()); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: expectedErrorMessage, + }), + ); + }); + it('should make a request to create a token on submit', () => { const mockAxios = new MockAdapter(axios); diff --git a/spec/frontend/design_management/components/design_todo_button_spec.js b/spec/frontend/design_management/components/design_todo_button_spec.js index b3afcefe1ed..ac26873b692 100644 --- a/spec/frontend/design_management/components/design_todo_button_spec.js +++ b/spec/frontend/design_management/components/design_todo_button_spec.js @@ -3,7 +3,7 @@ import { nextTick } from 'vue'; import DesignTodoButton from '~/design_management/components/design_todo_button.vue'; import createDesignTodoMutation from '~/design_management/graphql/mutations/create_design_todo.mutation.graphql'; import todoMarkDoneMutation from '~/graphql_shared/mutations/todo_mark_done.mutation.graphql'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; import mockDesign from '../mock_data/design'; const mockDesignWithPendingTodos = { 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 2b706d21f51..1acbf14db88 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,163 +1,229 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Design management design version dropdown component renders design version dropdown button 1`] = ` -<gl-dropdown-stub +<gl-base-dropdown-stub + ariahaspopup="listbox" category="primary" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" + icon="" issueiid="" projectpath="" size="small" - text="Showing latest version" + toggleid="dropdown-toggle-btn-2" + toggletext="Showing latest version" variant="default" > - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischecked="true" - ischeckitem="true" - secondarytext="" + <!----> + + <!----> + + <ul + aria-labelledby="dropdown-toggle-btn-2" + class="gl-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0" + id="listbox" + role="listbox" + tabindex="-1" > - <strong> - Version - 2 - (latest) - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischeckitem="true" - secondarytext="" - > - <strong> - Version - 1 - - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 2 (latest) + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> -</gl-dropdown-stub> + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 1 + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + </ul> + + <!----> + +</gl-base-dropdown-stub> `; exports[`Design management design version dropdown component renders design version list 1`] = ` -<gl-dropdown-stub +<gl-base-dropdown-stub + ariahaspopup="listbox" category="primary" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" + icon="" issueiid="" projectpath="" size="small" - text="Showing latest version" + toggleid="dropdown-toggle-btn-4" + toggletext="Showing latest version" variant="default" > - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischecked="true" - ischeckitem="true" - secondarytext="" + <!----> + + <!----> + + <ul + aria-labelledby="dropdown-toggle-btn-4" + class="gl-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0" + id="listbox" + role="listbox" + tabindex="-1" > - <strong> - Version - 2 - (latest) - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckcentered="true" - ischeckitem="true" - secondarytext="" - > - <strong> - Version - 1 - - </strong> - - <div - class="gl-text-gray-600 gl-mt-1" + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 2 (latest) + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + <gl-listbox-item-stub + ischeckcentered="true" > - <div> - Adminstrator - </div> - - <time-ago-stub - class="text-1" - cssclass="" - time="2021-08-09T06:05:00Z" - tooltipplacement="bottom" - /> - </div> - </gl-dropdown-item-stub> -</gl-dropdown-stub> + <span + class="gl-display-flex gl-align-items-center" + > + <div + class="gl-avatar gl-avatar-identicon gl-avatar-circle gl-avatar-s32 gl-avatar-identicon-bg1" + > + + + + </div> + + <span + class="gl-display-flex gl-flex-direction-column" + > + <span + class="gl-font-weight-bold" + > + Version 1 + </span> + + <span + class="gl-text-gray-600 gl-mt-1" + > + <span + class="gl-display-block" + > + Adminstrator + </span> + + <time-ago-stub + class="text-1" + cssclass="" + time="2021-08-09T06:05:00Z" + tooltipplacement="bottom" + /> + </span> + </span> + </span> + </gl-listbox-item-stub> + </ul> + + <!----> + +</gl-base-dropdown-stub> `; diff --git a/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js b/spec/frontend/design_management/components/upload/design_version_dropdown_spec.js index 7c26ab9739b..1e9f286a0ec 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,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlSprintf } from '@gitlab/ui'; +import { GlAvatar, GlCollapsibleListbox, GlListboxItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; import DesignVersionDropdown from '~/design_management/components/upload/design_version_dropdown.vue'; @@ -32,7 +32,7 @@ describe('Design management design version dropdown component', () => { mocks: { $route, }, - stubs: { GlSprintf }, + stubs: { GlAvatar, GlCollapsibleListbox }, }); // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details @@ -46,7 +46,9 @@ describe('Design management design version dropdown component', () => { wrapper.destroy(); }); - const findVersionLink = (index) => wrapper.findAllComponents(GlDropdownItem).at(index); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + const findAllListboxItems = () => wrapper.findAllComponents(GlListboxItem); + const findVersionLink = (index) => wrapper.findAllComponents(GlListboxItem).at(index); it('renders design version dropdown button', async () => { createComponent(); @@ -76,35 +78,36 @@ describe('Design management design version dropdown component', () => { createComponent(); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe('Showing latest version'); + + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('displays latest version text when only 1 version is present', async () => { createComponent({ maxVersions: 1 }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe('Showing latest version'); + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('displays version text when the current version is not the latest', async () => { createComponent({ $route: designRouteFactory(PREVIOUS_VERSION_ID) }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe(`Showing version #1`); + expect(findListbox().props('toggleText')).toBe(`Showing version #1`); }); it('displays latest version text when the current version is the latest', async () => { createComponent({ $route: designRouteFactory(LATEST_VERSION_ID) }); await nextTick(); - expect(wrapper.findComponent(GlDropdown).attributes('text')).toBe('Showing latest version'); + expect(findListbox().props('toggleText')).toBe('Showing latest version'); }); it('should have the same length as apollo query', async () => { createComponent(); await nextTick(); - expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(wrapper.vm.allVersions.length); + expect(findAllListboxItems()).toHaveLength(wrapper.vm.allVersions.length); }); it('should render TimeAgo', async () => { diff --git a/spec/frontend/diffs/components/diff_code_quality_spec.js b/spec/frontend/diffs/components/diff_code_quality_spec.js index b5dce4fc924..7bd9afab648 100644 --- a/spec/frontend/diffs/components/diff_code_quality_spec.js +++ b/spec/frontend/diffs/components/diff_code_quality_spec.js @@ -1,12 +1,14 @@ import { GlIcon } from '@gitlab/ui'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import DiffCodeQuality from '~/diffs/components/diff_code_quality.vue'; -import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/reports/codequality_report/constants'; +import { SEVERITY_CLASSES, SEVERITY_ICONS } from '~/ci/reports/codequality_report/constants'; +import { NEW_CODE_QUALITY_FINDINGS } from '~/diffs/i18n'; import { multipleFindingsArr } from '../mock_data/diff_code_quality'; let wrapper; const findIcon = () => wrapper.findComponent(GlIcon); +const findHeading = () => wrapper.findByTestId(`diff-codequality-findings-heading`); describe('DiffCodeQuality', () => { afterEach(() => { @@ -30,14 +32,17 @@ describe('DiffCodeQuality', () => { expect(wrapper.emitted('hideCodeQualityFindings').length).toBe(1); }); - it('renders correct amount of list items for codequality array and their description', async () => { + it('renders heading and correct amount of list items for codequality array and their description', async () => { wrapper = createWrapper(multipleFindingsArr); - const listItems = wrapper.findAll('li'); + expect(findHeading().text()).toEqual(NEW_CODE_QUALITY_FINDINGS); - expect(wrapper.findAll('li').length).toBe(3); + const listItems = wrapper.findAll('li'); + expect(wrapper.findAll('li').length).toBe(5); listItems.wrappers.map((e, i) => { - return expect(e.text()).toEqual(multipleFindingsArr[i].description); + return expect(e.text()).toContain( + `${multipleFindingsArr[i].severity} - ${multipleFindingsArr[i].description}`, + ); }); }); diff --git a/spec/frontend/diffs/components/diff_discussion_reply_spec.js b/spec/frontend/diffs/components/diff_discussion_reply_spec.js index 5ccd2002462..bf4a1a1c1f7 100644 --- a/spec/frontend/diffs/components/diff_discussion_reply_spec.js +++ b/spec/frontend/diffs/components/diff_discussion_reply_spec.js @@ -1,10 +1,12 @@ import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; import Vue from 'vue'; import Vuex from 'vuex'; import DiffDiscussionReply from '~/diffs/components/diff_discussion_reply.vue'; -import ReplyPlaceholder from '~/notes/components/discussion_reply_placeholder.vue'; import NoteSignedOutWidget from '~/notes/components/note_signed_out_widget.vue'; +import { START_THREAD } from '~/diffs/i18n'; + Vue.use(Vuex); describe('DiffDiscussionReply', () => { @@ -58,14 +60,42 @@ describe('DiffDiscussionReply', () => { expect(wrapper.find('#test-form').exists()).toBe(true); }); - it('should render a reply placeholder if there is no form', () => { + it('should render a reply placeholder button if there is no form', () => { createComponent({ renderReplyPlaceholder: true, hasForm: false, }); - expect(wrapper.findComponent(ReplyPlaceholder).exists()).toBe(true); + expect(wrapper.findComponent(GlButton).text()).toBe(START_THREAD); }); + + it.each` + userCanReply | hasForm | renderReplyPlaceholder | showButton + ${false} | ${false} | ${false} | ${false} + ${true} | ${false} | ${false} | ${false} + ${true} | ${true} | ${false} | ${false} + ${true} | ${true} | ${true} | ${false} + ${true} | ${false} | ${true} | ${true} + ${false} | ${false} | ${true} | ${false} + `( + 'reply button existence is `$showButton` when userCanReply is `$userCanReply`, hasForm is `$hasForm` and renderReplyPlaceholder is `$renderReplyPlaceholder`', + ({ userCanReply, hasForm, renderReplyPlaceholder, showButton }) => { + getters = { + userCanReply: () => userCanReply, + }; + + store = new Vuex.Store({ + getters, + }); + + createComponent({ + renderReplyPlaceholder, + hasForm, + }); + + expect(wrapper.findComponent(GlButton).exists()).toBe(showButton); + }, + ); }); it('renders a signed out widget when user is not logged in', () => { diff --git a/spec/frontend/diffs/components/diff_discussions_spec.js b/spec/frontend/diffs/components/diff_discussions_spec.js index e9a0e0745fd..5092ae6ab6e 100644 --- a/spec/frontend/diffs/components/diff_discussions_spec.js +++ b/spec/frontend/diffs/components/diff_discussions_spec.js @@ -5,9 +5,10 @@ import { createStore } from '~/mr_notes/stores'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; -import '~/behaviors/markdown/render_gfm'; import discussionsMockData from '../mock_data/diff_discussions'; +jest.mock('~/behaviors/markdown/render_gfm'); + describe('DiffDiscussions', () => { let store; let wrapper; diff --git a/spec/frontend/diffs/mock_data/diff_code_quality.js b/spec/frontend/diffs/mock_data/diff_code_quality.js index befab3b676b..7558592f6a4 100644 --- a/spec/frontend/diffs/mock_data/diff_code_quality.js +++ b/spec/frontend/diffs/mock_data/diff_code_quality.js @@ -1,25 +1,39 @@ export const multipleFindingsArr = [ { severity: 'minor', - description: 'Unexpected Debugger Statement.', + description: 'mocked minor Issue', line: 2, }, { severity: 'major', - description: - 'Function `aVeryLongFunction` has 52 lines of code (exceeds 25 allowed). Consider refactoring.', + description: 'mocked major Issue', line: 3, }, { - severity: 'minor', - description: 'Arrow function has too many statements (52). Maximum allowed is 30.', + severity: 'info', + description: 'mocked info Issue', + line: 3, + }, + { + severity: 'critical', + description: 'mocked critical Issue', + line: 3, + }, + { + severity: 'blocker', + description: 'mocked blocker Issue', line: 3, }, ]; -export const multipleFindings = { +export const fiveFindings = { + filePath: 'index.js', + codequality: multipleFindingsArr.slice(0, 5), +}; + +export const threeFindings = { filePath: 'index.js', - codequality: multipleFindingsArr, + codequality: multipleFindingsArr.slice(0, 3), }; export const singularFinding = { diff --git a/spec/frontend/diffs/store/actions_spec.js b/spec/frontend/diffs/store/actions_spec.js index 87366cdbfc5..9e0ffbf757f 100644 --- a/spec/frontend/diffs/store/actions_spec.js +++ b/spec/frontend/diffs/store/actions_spec.js @@ -606,6 +606,50 @@ describe('DiffsStoreActions', () => { params: { commit_id: '123', w: '0' }, }); }); + + describe('version parameters', () => { + const diffId = '4'; + const startSha = 'abc'; + const pathRoot = 'a/a/-/merge_requests/1'; + let file; + let getters; + + beforeAll(() => { + file = { load_collapsed_diff_url: '/load/collapsed/diff/url' }; + getters = {}; + }); + + beforeEach(() => { + jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); + }); + + it('fetches the data when there is no mergeRequestDiff', () => { + diffActions.loadCollapsedDiff({ commit() {}, getters, state }, file); + + expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { + params: expect.any(Object), + }); + }); + + it.each` + desc | versionPath | start_sha | diff_id + ${'no additional version information'} | ${`${pathRoot}?search=terms`} | ${undefined} | ${undefined} + ${'the diff_id'} | ${`${pathRoot}?diff_id=${diffId}`} | ${undefined} | ${diffId} + ${'the start_sha'} | ${`${pathRoot}?start_sha=${startSha}`} | ${startSha} | ${undefined} + ${'all available version information'} | ${`${pathRoot}?diff_id=${diffId}&start_sha=${startSha}`} | ${startSha} | ${diffId} + `('fetches the data and includes $desc', ({ versionPath, start_sha, diff_id }) => { + jest.spyOn(axios, 'get').mockReturnValue(Promise.resolve({ data: {} })); + + diffActions.loadCollapsedDiff( + { commit() {}, getters, state: { mergeRequestDiff: { version_path: versionPath } } }, + file, + ); + + expect(axios.get).toHaveBeenCalledWith(file.load_collapsed_diff_url, { + params: expect.objectContaining({ start_sha, diff_id }), + }); + }); + }); }); describe('toggleFileDiscussions', () => { diff --git a/spec/frontend/diffs/utils/merge_request_spec.js b/spec/frontend/diffs/utils/merge_request_spec.js index 8c7b1e1f2a5..c070e8c004d 100644 --- a/spec/frontend/diffs/utils/merge_request_spec.js +++ b/spec/frontend/diffs/utils/merge_request_spec.js @@ -2,30 +2,64 @@ import { getDerivedMergeRequestInformation } from '~/diffs/utils/merge_request'; import { diffMetadata } from '../mock_data/diff_metadata'; describe('Merge Request utilities', () => { - const derivedMrInfo = { + const derivedBaseInfo = { mrPath: '/gitlab-org/gitlab-test/-/merge_requests/4', userOrGroup: 'gitlab-org', project: 'gitlab-test', id: '4', }; + const derivedVersionInfo = { + diffId: '4', + startSha: 'eb227b3e214624708c474bdab7bde7afc17cefcc', + }; + const noVersion = { + diffId: undefined, + startSha: undefined, + }; const unparseableEndpoint = { mrPath: undefined, userOrGroup: undefined, project: undefined, id: undefined, + ...noVersion, }; describe('getDerivedMergeRequestInformation', () => { - const endpoint = `${diffMetadata.latest_version_path}.json?searchParam=irrelevant`; + let endpoint = `${diffMetadata.latest_version_path}.json?searchParam=irrelevant`; it.each` argument | response - ${{ endpoint }} | ${derivedMrInfo} + ${{ endpoint }} | ${{ ...derivedBaseInfo, ...noVersion }} ${{}} | ${unparseableEndpoint} ${{ endpoint: undefined }} | ${unparseableEndpoint} ${{ endpoint: null }} | ${unparseableEndpoint} `('generates the correct derived results based on $argument', ({ argument, response }) => { expect(getDerivedMergeRequestInformation(argument)).toStrictEqual(response); }); + + describe('version information', () => { + const bare = diffMetadata.latest_version_path; + endpoint = diffMetadata.merge_request_diffs[0].compare_path; + + it('still gets the correct derived information', () => { + expect(getDerivedMergeRequestInformation({ endpoint })).toMatchObject(derivedBaseInfo); + }); + + it.each` + url | versionPart + ${endpoint} | ${derivedVersionInfo} + ${`${bare}?diff_id=${derivedVersionInfo.diffId}`} | ${{ ...derivedVersionInfo, startSha: undefined }} + ${`${bare}?start_sha=${derivedVersionInfo.startSha}`} | ${{ ...derivedVersionInfo, diffId: undefined }} + `( + 'generates the correct derived version information based on $url', + ({ url, versionPart }) => { + expect(getDerivedMergeRequestInformation({ endpoint: url })).toMatchObject(versionPart); + }, + ); + + it('extracts nothing if there is no available version-like information in the URL', () => { + expect(getDerivedMergeRequestInformation({ endpoint: bare })).toMatchObject(noVersion); + }); + }); }); }); diff --git a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js index 1475d451ab3..ff377494312 100644 --- a/spec/frontend/editor/components/source_editor_toolbar_button_spec.js +++ b/spec/frontend/editor/components/source_editor_toolbar_button_spec.js @@ -15,6 +15,9 @@ describe('Source Editor Toolbar button', () => { propsData: { ...props, }, + stubs: { + GlButton, + }, }); }; @@ -52,9 +55,69 @@ describe('Source Editor Toolbar button', () => { const btn = findButton(); expect(btn.props()).toMatchObject(customProps); }); + + describe('CSS class', () => { + let blueprintClasses; + + beforeEach(() => { + createComponent(); + blueprintClasses = findButton().element.classList; + }); + + it.each` + cssClass | expectedExtraClasses + ${undefined} | ${['']} + ${''} | ${['']} + ${'foo'} | ${['foo']} + ${'foo bar'} | ${['foo', 'bar']} + `( + 'does set CSS class correctly when `class` is "$cssClass"', + ({ cssClass, expectedExtraClasses }) => { + createComponent({ + button: { + ...defaultBtn, + class: cssClass, + }, + }); + const btn = findButton().element; + expectedExtraClasses.forEach((c) => { + if (c) { + expect(btn.classList.contains(c)).toBe(true); + } else { + expect(btn.classList).toEqual(blueprintClasses); + } + }); + }, + ); + }); + }); + + describe('data attributes', () => { + it.each` + description | data | expectedDataset + ${'does not set any attribute'} | ${undefined} | ${{}} + ${'does not set any attribute'} | ${[]} | ${{}} + ${'does not set any attribute'} | ${['foo']} | ${{}} + ${'does not set any attribute'} | ${'bar'} | ${{}} + ${'does set single attribute correctly'} | ${{ qaSelector: 'foo' }} | ${{ qaSelector: 'foo' }} + ${'does set multiple attributes correctly'} | ${{ qaSelector: 'foo', youCanSeeMe: true }} | ${{ qaSelector: 'foo', youCanSeeMe: 'true' }} + `('$description when data="$data"', ({ data, expectedDataset }) => { + createComponent({ + button: { + data, + }, + }); + expect(findButton().element.dataset).toEqual(expect.objectContaining(expectedDataset)); + }); }); describe('click handler', () => { + let clickEvent; + + beforeEach(() => { + clickEvent = new Event('click'); + }); + it('fires the click handler on the button when available', async () => { const spy = jest.fn(); createComponent({ @@ -63,20 +126,20 @@ describe('Source Editor Toolbar button', () => { }, }); expect(spy).not.toHaveBeenCalled(); - findButton().vm.$emit('click'); + findButton().vm.$emit('click', clickEvent); await nextTick(); - expect(spy).toHaveBeenCalled(); + expect(spy).toHaveBeenCalledWith(clickEvent); }); - it('emits the "click" event', async () => { + it('emits the "click" event, passing the event itself', async () => { createComponent(); jest.spyOn(wrapper.vm, '$emit'); expect(wrapper.vm.$emit).not.toHaveBeenCalled(); - findButton().vm.$emit('click'); + findButton().vm.$emit('click', clickEvent); await nextTick(); - expect(wrapper.vm.$emit).toHaveBeenCalledWith('click'); + expect(wrapper.vm.$emit).toHaveBeenCalledWith('click', clickEvent); }); }); }); diff --git a/spec/frontend/editor/schema/ci/ci_schema_spec.js b/spec/frontend/editor/schema/ci/ci_schema_spec.js index 32126a5fd9a..c822a0bfeaf 100644 --- a/spec/frontend/editor/schema/ci/ci_schema_spec.js +++ b/spec/frontend/editor/schema/ci/ci_schema_spec.js @@ -30,6 +30,9 @@ import RulesYaml from './yaml_tests/positive_tests/rules.yml'; import ProjectPathYaml from './yaml_tests/positive_tests/project_path.yml'; import VariablesYaml from './yaml_tests/positive_tests/variables.yml'; import JobWhenYaml from './yaml_tests/positive_tests/job_when.yml'; +import IdTokensYaml from './yaml_tests/positive_tests/id_tokens.yml'; +import HooksYaml from './yaml_tests/positive_tests/hooks.yml'; +import SecretsYaml from './yaml_tests/positive_tests/secrets.yml'; // YAML NEGATIVE TEST import ArtifactsNegativeYaml from './yaml_tests/negative_tests/artifacts.yml'; @@ -43,8 +46,12 @@ import ProjectPathIncludeNoSlashYaml from './yaml_tests/negative_tests/project_p import ProjectPathIncludeTailSlashYaml from './yaml_tests/negative_tests/project_path/include/tailing_slash.yml'; import RulesNegativeYaml from './yaml_tests/negative_tests/rules.yml'; import TriggerNegative from './yaml_tests/negative_tests/trigger.yml'; +import VariablesInvalidOptionsYaml from './yaml_tests/negative_tests/variables/invalid_options.yml'; import VariablesInvalidSyntaxDescYaml from './yaml_tests/negative_tests/variables/invalid_syntax_desc.yml'; import VariablesWrongSyntaxUsageExpand from './yaml_tests/negative_tests/variables/wrong_syntax_usage_expand.yml'; +import IdTokensNegativeYaml from './yaml_tests/negative_tests/id_tokens.yml'; +import HooksNegative from './yaml_tests/negative_tests/hooks.yml'; +import SecretsNegativeYaml from './yaml_tests/negative_tests/secrets.yml'; const ajv = new Ajv({ strictTypes: false, @@ -77,9 +84,12 @@ describe('positive tests', () => { FilterYaml, IncludeYaml, JobWhenYaml, + HooksYaml, RulesYaml, VariablesYaml, ProjectPathYaml, + IdTokensYaml, + SecretsYaml, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a @@ -103,9 +113,11 @@ describe('negative tests', () => { // YAML ArtifactsNegativeYaml, CacheKeyNeative, + IdTokensNegativeYaml, IncludeNegativeYaml, JobWhenNegativeYaml, RulesNegativeYaml, + VariablesInvalidOptionsYaml, VariablesInvalidSyntaxDescYaml, VariablesWrongSyntaxUsageExpand, ProjectPathIncludeEmptyYaml, @@ -113,7 +125,9 @@ describe('negative tests', () => { ProjectPathIncludeLeadSlashYaml, ProjectPathIncludeNoSlashYaml, ProjectPathIncludeTailSlashYaml, + SecretsNegativeYaml, TriggerNegative, + HooksNegative, }), )('schema validates %s', (_, input) => { // We construct a new "JSON" from each main key that is inside a diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml index f5670376efc..29f4a0cd76d 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/artifacts.yml @@ -16,3 +16,16 @@ cyclonedx not an array or string: paths: - foo - bar + +# invalid artifacts:when +artifacts-when-unknown: + artifacts: + when: unknown + +artifacts-when-array: + artifacts: + when: [always] + +artifacts-when-boolean: + artifacts: + when: true diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml index 3979c9ae2ac..9baed2a7922 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/cache.yml @@ -51,12 +51,39 @@ cache-untracked-string: cache: untracked: 'true' -when_integer: +# invalid cache:when +cache-when-integer: script: echo "This job uses a cache." cache: when: 0 -when_not_reserved_keyword: +cache-when-array: + script: echo "This job uses a cache." + cache: + when: [always] + +cache-when-boolean: + script: echo "This job uses a cache." + cache: + when: true + +cache-when-never: script: echo "This job uses a cache." cache: when: 'never' + +# invalid cache:policy +cache-policy-array: + script: echo "This job uses a cache." + cache: + policy: [push] + +cache-policy-boolean: + script: echo "This job uses a cache." + cache: + policy: true + +cache-when-unknown: + script: echo "This job uses a cache." + cache: + policy: unknown diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/hooks.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/hooks.yml new file mode 100644 index 00000000000..e3366b0b6d3 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/hooks.yml @@ -0,0 +1,10 @@ +job1: + hooks: + invalid_script: + - echo 'hello job1 invalid_script' + script: echo 'hello job1 script' + +job2: + hooks: + pre_get_sources_script: true + script: echo 'hello job1 script' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/id_tokens.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/id_tokens.yml new file mode 100644 index 00000000000..aff2611f16c --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/id_tokens.yml @@ -0,0 +1,11 @@ +id_token_with_wrong_aud_type: + id_tokens: + INVALID_ID_TOKEN: + aud: + invalid_prop: invalid + +id_token_with_extra_properties: + id_tokens: + INVALID_ID_TOKEN: + aud: 'https://gitlab.com' + sub: 'not a valid property' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml new file mode 100644 index 00000000000..14ba930b394 --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/secrets.yml @@ -0,0 +1,39 @@ +job_with_secrets_without_vault: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + token: $TEST_TOKEN + +job_with_secrets_with_extra_properties: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: test/db/password + extra_prop: TEST + +job_with_secrets_with_invalid_vault_property: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: + invalid: TEST + +job_with_secrets_with_missing_required_vault_property: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: + path: gitlab + +job_with_secrets_with_missing_required_engine_property: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: + engine: + path: kv diff --git a/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/variables/invalid_options.yml b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/variables/invalid_options.yml new file mode 100644 index 00000000000..aac4c4e456d --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/negative_tests/variables/invalid_options.yml @@ -0,0 +1,4 @@ +variables: + INVALID_OPTIONS: + value: "staging" + options: "staging" # must be an array diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml index 20c1fc2c50f..a5c9153ee13 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/artifacts.yml @@ -23,3 +23,13 @@ cylonedx mixed list of string paths and globs: cyclonedx: - ./foo - "bar/*.baz" + +# valid artifacts:when +artifacts-when-on-failure: + artifacts: + when: on_failure + +artifacts-no-when: + artifacts: + paths: + - binaries/ diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml index 75918cd2a1b..d50b74e1448 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/cache.yml @@ -122,3 +122,20 @@ cache-untracked-false: script: test cache: untracked: false + +# valid cache:policy +cache-policy-push: + script: echo "This job uses a cache." + cache: + policy: push + +cache-policy-pull: + script: echo "This job uses a cache." + cache: + policy: pull + +cache-no-policy: + script: echo "This job uses a cache." + cache: + paths: + - binaries/ diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/hooks.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/hooks.yml new file mode 100644 index 00000000000..4d45c5528ea --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/hooks.yml @@ -0,0 +1,10 @@ +default: + hooks: + pre_get_sources_script: + - echo 'hello default pre_get_sources_script' + +job1: + hooks: + pre_get_sources_script: + - echo 'hello job1 pre_get_sources_script' + script: echo 'hello job1 script' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/id_tokens.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/id_tokens.yml new file mode 100644 index 00000000000..169b09ee56f --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/id_tokens.yml @@ -0,0 +1,11 @@ +valid_id_tokens: + script: + - echo $ID_TOKEN_1 + - echo $ID_TOKEN_2 + id_tokens: + ID_TOKEN_1: + aud: 'https://gitlab.com' + ID_TOKEN_2: + aud: + - 'https://aws.com' + - 'https://google.com' diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml new file mode 100644 index 00000000000..083cb4348ed --- /dev/null +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/secrets.yml @@ -0,0 +1,28 @@ +valid_job_with_secrets: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: test/db/password + +valid_job_with_secrets_and_token: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: test/db/password + token: $TEST_TOKEN + +valid_job_with_secrets_with_every_vault_keyword: + script: + - echo $TEST_DB_PASSWORD + secrets: + TEST_DB_PASSWORD: + vault: + engine: + name: test-engine + path: test + path: test/db + field: password + file: true + token: $TEST_TOKEN diff --git a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml index 53d020c432f..5c91de9be70 100644 --- a/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml +++ b/spec/frontend/editor/schema/ci/yaml_tests/positive_tests/variables.yml @@ -4,11 +4,18 @@ variables: FOO: value: "BAR" description: "A single value variable" - DEPLOY_ENVIRONMENT: + VAR_WITH_DESCRIPTION: description: "A multi-value variable" RAW_VAR: value: "Hello $FOO" expand: false + VAR_WITH_OPTIONS: + value: "staging" + options: + - "production" + - "staging" + - "canary" + description: "The deployment target. Set to 'production' by default." rspec: script: rspec diff --git a/spec/frontend/editor/source_editor_markdown_ext_spec.js b/spec/frontend/editor/source_editor_markdown_ext_spec.js index 3e8c287df2f..33e4b4bfc8e 100644 --- a/spec/frontend/editor/source_editor_markdown_ext_spec.js +++ b/spec/frontend/editor/source_editor_markdown_ext_spec.js @@ -1,7 +1,9 @@ import MockAdapter from 'axios-mock-adapter'; import { Range, Position } from 'monaco-editor'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { EXTENSION_MARKDOWN_BUTTONS } from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; +import { ToolbarExtension } from '~/editor/extensions/source_editor_toolbar_ext'; import SourceEditor from '~/editor/source_editor'; import axios from '~/lib/utils/axios_utils'; @@ -36,7 +38,7 @@ describe('Markdown Extension for Source Editor', () => { blobPath: markdownPath, blobContent: text, }); - instance.use({ definition: EditorMarkdownExtension }); + instance.use([{ definition: ToolbarExtension }, { definition: EditorMarkdownExtension }]); }); afterEach(() => { @@ -47,6 +49,16 @@ describe('Markdown Extension for Source Editor', () => { resetHTMLFixture(); }); + describe('toolbar', () => { + it('renders all the buttons', () => { + const btns = instance.toolbar.getAllItems(); + expect(btns).toHaveLength(EXTENSION_MARKDOWN_BUTTONS.length); + EXTENSION_MARKDOWN_BUTTONS.forEach((btn, i) => { + expect(btns[i].id).toBe(btn.id); + }); + }); + }); + describe('getSelectedText', () => { it('does not fail if there is no selection and returns the empty string', () => { jest.spyOn(instance, 'getSelection'); diff --git a/spec/frontend/environment.js b/spec/frontend/environment.js index 1c84350bd8e..82e3b50aeb8 100644 --- a/spec/frontend/environment.js +++ b/spec/frontend/environment.js @@ -1,7 +1,7 @@ /* eslint-disable import/no-commonjs, max-classes-per-file */ const path = require('path'); -const JSDOMEnvironment = require('jest-environment-jsdom'); +const { TestEnvironment } = require('jest-environment-jsdom'); const { ErrorWithStack } = require('jest-util'); const { setGlobalDateToFakeDate, @@ -11,10 +11,10 @@ const { TEST_HOST } = require('./__helpers__/test_constants'); const ROOT_PATH = path.resolve(__dirname, '../..'); -class CustomEnvironment extends JSDOMEnvironment { - constructor(config, context) { +class CustomEnvironment extends TestEnvironment { + constructor({ globalConfig, projectConfig }, context) { // Setup testURL so that window.location is setup properly - super({ ...config, testURL: TEST_HOST }, context); + super({ globalConfig, projectConfig: { ...projectConfig, testURL: TEST_HOST } }, context); // Fake the `Date` for `jsdom` which fixes things like document.cookie // https://gitlab.com/gitlab-org/gitlab/-/merge_requests/39496#note_503084332 @@ -39,8 +39,7 @@ class CustomEnvironment extends JSDOMEnvironment { }, }); - const { testEnvironmentOptions } = config; - const { IS_EE } = testEnvironmentOptions; + const { IS_EE } = projectConfig.testEnvironmentOptions; this.global.gon = { ee: IS_EE, }; diff --git a/spec/frontend/environments/environment_details_page_spec.js b/spec/frontend/environments/environment_details_page_spec.js new file mode 100644 index 00000000000..5a02b34250f --- /dev/null +++ b/spec/frontend/environments/environment_details_page_spec.js @@ -0,0 +1,50 @@ +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlLoadingIcon, GlTableLite } from '@gitlab/ui'; +import resolvedEnvironmentDetails from 'test_fixtures/graphql/environments/graphql/queries/environment_details.query.graphql.json'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from '../__helpers__/mock_apollo_helper'; +import waitForPromises from '../__helpers__/wait_for_promises'; +import EnvironmentsDetailPage from '../../../app/assets/javascripts/environments/environment_details/index.vue'; +import getEnvironmentDetails from '../../../app/assets/javascripts/environments/graphql/queries/environment_details.query.graphql'; + +describe('~/environments/environment_details/page.vue', () => { + Vue.use(VueApollo); + + let wrapper; + + const createWrapper = () => { + const mockApollo = createMockApollo([ + [getEnvironmentDetails, jest.fn().mockResolvedValue(resolvedEnvironmentDetails)], + ]); + + return mountExtended(EnvironmentsDetailPage, { + apolloProvider: mockApollo, + propsData: { + projectFullPath: resolvedEnvironmentDetails.data.project.fullPath, + environmentName: resolvedEnvironmentDetails.data.project.environment.name, + }, + }); + }; + + describe('when fetching data', () => { + it('should show a loading indicator', () => { + wrapper = createWrapper(); + + expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); + expect(wrapper.findComponent(GlTableLite).exists()).not.toBe(true); + }); + }); + + describe('when data is fetched', () => { + beforeEach(async () => { + wrapper = createWrapper(); + await waitForPromises(); + }); + + it('should render a table when query is loaded', async () => { + expect(wrapper.findComponent(GlLoadingIcon).exists()).not.toBe(true); + expect(wrapper.findComponent(GlTableLite).exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap new file mode 100644 index 00000000000..401c10338c1 --- /dev/null +++ b/spec/frontend/environments/helpers/__snapshots__/deployment_data_transformation_helper_spec.js.snap @@ -0,0 +1,127 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`deployment_data_transformation_helper convertToDeploymentTableRow should be converted to proper table row data 1`] = ` +Object { + "commit": Object { + "author": Object { + "avatar_url": "/uploads/-/system/user/avatar/1/avatar.png", + "path": "http://gdk.test:3000/root", + "username": "Administrator", + }, + "commitRef": Object { + "name": "main", + }, + "commitUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + "shortSha": "0cb48dd5", + "tag": false, + "title": "Update .gitlab-ci.yml file", + }, + "created": "2022-10-17T07:44:17Z", + "deployed": "2022-10-17T07:44:43Z", + "id": "31", + "job": Object { + "label": "deploy-prod (#860)", + "webPath": "/gitlab-org/pipelinestest/-/jobs/860", + }, + "status": "success", + "triggerer": Object { + "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", + "id": "gid://gitlab/User/1", + "name": "Administrator", + "webUrl": "http://gdk.test:3000/root", + }, +} +`; + +exports[`deployment_data_transformation_helper convertToDeploymentTableRow should be converted to proper table row data 2`] = ` +Object { + "commit": Object { + "author": Object { + "avatar_url": "/uploads/-/system/user/avatar/1/avatar.png", + "path": "http://gdk.test:3000/root", + "username": "Administrator", + }, + "commitRef": Object { + "name": "main", + }, + "commitUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + "shortSha": "0cb48dd5", + "tag": false, + "title": "Update .gitlab-ci.yml file", + }, + "created": "2022-10-17T07:44:17Z", + "deployed": "2022-10-17T07:44:43Z", + "id": "31", + "job": undefined, + "status": "success", + "triggerer": Object { + "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", + "id": "gid://gitlab/User/1", + "name": "Administrator", + "webUrl": "http://gdk.test:3000/root", + }, +} +`; + +exports[`deployment_data_transformation_helper convertToDeploymentTableRow should be converted to proper table row data 3`] = ` +Object { + "commit": Object { + "author": Object { + "avatar_url": "/uploads/-/system/user/avatar/1/avatar.png", + "path": "http://gdk.test:3000/root", + "username": "Administrator", + }, + "commitRef": Object { + "name": "main", + }, + "commitUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + "shortSha": "0cb48dd5", + "tag": false, + "title": "Update .gitlab-ci.yml file", + }, + "created": "2022-10-17T07:44:17Z", + "deployed": "", + "id": "31", + "job": null, + "status": "success", + "triggerer": Object { + "avatarUrl": "/uploads/-/system/user/avatar/1/avatar.png", + "id": "gid://gitlab/User/1", + "name": "Administrator", + "webUrl": "http://gdk.test:3000/root", + }, +} +`; + +exports[`deployment_data_transformation_helper getAuthorFromCommit should be properly converted 1`] = ` +Object { + "avatar_url": "/uploads/-/system/user/avatar/1/avatar.png", + "path": "http://gdk.test:3000/root", + "username": "Administrator", +} +`; + +exports[`deployment_data_transformation_helper getAuthorFromCommit should be properly converted 2`] = ` +Object { + "avatar_url": "https://www.gravatar.com/avatar/91811aee1dec1b2655fa56f894e9e7c9?s=80&d=identicon", + "path": "mailto:azubov@gitlab.com", + "username": "Andrei Zubov", +} +`; + +exports[`deployment_data_transformation_helper getCommitFromDeploymentNode should get correclty formatted commit object 1`] = ` +Object { + "author": Object { + "avatar_url": "/uploads/-/system/user/avatar/1/avatar.png", + "path": "http://gdk.test:3000/root", + "username": "Administrator", + }, + "commitRef": Object { + "name": "main", + }, + "commitUrl": "http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74", + "shortSha": "0cb48dd5", + "tag": false, + "title": "Update .gitlab-ci.yml file", +} +`; diff --git a/spec/frontend/environments/helpers/deployment_data_transformation_helper_spec.js b/spec/frontend/environments/helpers/deployment_data_transformation_helper_spec.js new file mode 100644 index 00000000000..8bb87c0a208 --- /dev/null +++ b/spec/frontend/environments/helpers/deployment_data_transformation_helper_spec.js @@ -0,0 +1,96 @@ +import { + getAuthorFromCommit, + getCommitFromDeploymentNode, + convertToDeploymentTableRow, +} from '~/environments/helpers/deployment_data_transformation_helper'; + +describe('deployment_data_transformation_helper', () => { + const commitWithAuthor = { + id: 'gid://gitlab/CommitPresenter/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74', + shortId: '0cb48dd5', + message: 'Update .gitlab-ci.yml file', + webUrl: + 'http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/0cb48dd5deddb7632fd7c3defb16075fc6c3ca74', + authorGravatar: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + authorName: 'Administrator', + authorEmail: 'admin@example.com', + author: { + id: 'gid://gitlab/User/1', + name: 'Administrator', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png', + webUrl: 'http://gdk.test:3000/root', + }, + }; + + const commitWithourAuthor = { + id: 'gid://gitlab/CommitPresenter/02274a949a88c9aef68a29685d99bd9a661a7f9b', + shortId: '02274a94', + message: 'Commit message', + webUrl: + 'http://gdk.test:3000/gitlab-org/pipelinestest/-/commit/02274a949a88c9aef68a29685d99bd9a661a7f9b', + authorGravatar: + 'https://www.gravatar.com/avatar/91811aee1dec1b2655fa56f894e9e7c9?s=80&d=identicon', + authorName: 'Andrei Zubov', + authorEmail: 'azubov@gitlab.com', + author: null, + }; + + const deploymentNode = { + id: 'gid://gitlab/Deployment/76', + iid: '31', + status: 'SUCCESS', + createdAt: '2022-10-17T07:44:17Z', + ref: 'main', + tag: false, + job: { + name: 'deploy-prod', + refName: 'main', + id: 'gid://gitlab/Ci::Build/860', + webPath: '/gitlab-org/pipelinestest/-/jobs/860', + }, + commit: commitWithAuthor, + triggerer: { + id: 'gid://gitlab/User/1', + webUrl: 'http://gdk.test:3000/root', + name: 'Administrator', + avatarUrl: '/uploads/-/system/user/avatar/1/avatar.png', + }, + finishedAt: '2022-10-17T07:44:43Z', + }; + + const deploymentNodeWithNoJob = { + ...deploymentNode, + job: null, + finishedAt: null, + }; + + describe('getAuthorFromCommit', () => { + it.each([commitWithAuthor, commitWithourAuthor])('should be properly converted', (commit) => { + expect(getAuthorFromCommit(commit)).toMatchSnapshot(); + }); + }); + + describe('getCommitFromDeploymentNode', () => { + it('should throw an error when commit field is missing', () => { + const emptyDeploymentNode = {}; + + expect(() => getCommitFromDeploymentNode(emptyDeploymentNode)).toThrow(); + }); + + it('should get correclty formatted commit object', () => { + expect(getCommitFromDeploymentNode(deploymentNode)).toMatchSnapshot(); + }); + }); + + describe('convertToDeploymentTableRow', () => { + const deploymentNodeWithEmptyJob = { ...deploymentNode, job: undefined }; + + it.each([deploymentNode, deploymentNodeWithEmptyJob, deploymentNodeWithNoJob])( + 'should be converted to proper table row data', + (node) => { + expect(convertToDeploymentTableRow(node)).toMatchSnapshot(); + }, + ); + }); +}); diff --git a/spec/frontend/feature_flags/components/feature_flags_table_spec.js b/spec/frontend/feature_flags/components/feature_flags_table_spec.js index 47f12f70056..f23bca54b55 100644 --- a/spec/frontend/feature_flags/components/feature_flags_table_spec.js +++ b/spec/frontend/feature_flags/components/feature_flags_table_spec.js @@ -1,6 +1,6 @@ -import { GlToggle, GlBadge } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { GlToggle } from '@gitlab/ui'; import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; import { mockTracking } from 'helpers/tracking_helper'; import FeatureFlagsTable from '~/feature_flags/components/feature_flags_table.vue'; @@ -52,10 +52,10 @@ const getDefaultProps = () => ({ describe('Feature flag table', () => { let wrapper; let props; - let badges; + let labels; const createWrapper = (propsData, opts = {}) => { - wrapper = shallowMount(FeatureFlagsTable, { + wrapper = mountExtended(FeatureFlagsTable, { propsData, provide: { csrfToken: 'fakeToken', @@ -70,18 +70,13 @@ describe('Feature flag table', () => { provide: { csrfToken: 'fakeToken' }, }); - badges = wrapper.findAll('[data-testid="strategy-badge"]'); + labels = wrapper.findAllByTestId('strategy-label'); }); beforeEach(() => { props = getDefaultProps(); }); - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - describe('with an active scope and a standard rollout strategy', () => { beforeEach(() => { createWrapper(props); @@ -101,7 +96,7 @@ describe('Feature flag table', () => { }); it('Should render a status column', () => { - const badge = wrapper.find('[data-testid="feature-flag-status-badge"]'); + const badge = wrapper.findByTestId('feature-flag-status-badge'); expect(badge.exists()).toBe(true); expect(trimText(badge.text())).toEqual('Active'); @@ -116,10 +111,10 @@ describe('Feature flag table', () => { ); }); - it('should render an environments specs badge with active class', () => { - const envColumn = wrapper.find('.js-feature-flag-environments'); + it('should render an environments specs label', () => { + const strategyLabel = wrapper.findByTestId('strategy-label'); - expect(trimText(envColumn.findComponent(GlBadge).text())).toBe('All Users: All Environments'); + expect(trimText(strategyLabel.text())).toBe('All Users: All Environments'); }); it('should render an actions column', () => { @@ -167,29 +162,29 @@ describe('Feature flag table', () => { }); it('shows All Environments if the environment scope is *', () => { - expect(badges.at(0).text()).toContain('All Environments'); + expect(labels.at(0).text()).toContain('All Environments'); }); it('shows the environment scope if another is set', () => { - expect(badges.at(1).text()).toContain('production'); - expect(badges.at(1).text()).toContain('staging'); - expect(badges.at(2).text()).toContain('review/*'); + expect(labels.at(1).text()).toContain('production'); + expect(labels.at(1).text()).toContain('staging'); + expect(labels.at(2).text()).toContain('review/*'); }); it('shows All Users for the default strategy', () => { - expect(badges.at(0).text()).toContain('All Users'); + expect(labels.at(0).text()).toContain('All Users'); }); it('shows the percent for a percent rollout', () => { - expect(badges.at(1).text()).toContain('Percent of users - 50%'); + expect(labels.at(1).text()).toContain('Percent of users - 50%'); }); it('shows the number of users for users with ID', () => { - expect(badges.at(2).text()).toContain('User IDs - 4 users'); + expect(labels.at(2).text()).toContain('User IDs - 4 users'); }); it('shows the name of a user list for user list', () => { - expect(badges.at(3).text()).toContain('User List - test list'); + expect(labels.at(3).text()).toContain('User List - test list'); }); it('renders a feature flag without an iid', () => { diff --git a/spec/frontend/feature_flags/components/strategy_label_spec.js b/spec/frontend/feature_flags/components/strategy_label_spec.js new file mode 100644 index 00000000000..c2d5ce10448 --- /dev/null +++ b/spec/frontend/feature_flags/components/strategy_label_spec.js @@ -0,0 +1,61 @@ +import { mount } from '@vue/test-utils'; +import StrategyLabel from '~/feature_flags/components/strategy_label.vue'; + +const DEFAULT_PROPS = { + name: 'All Users', + parameters: 'parameters', + scopes: 'scope1, scope2', +}; + +describe('feature_flags/components/feature_flags_tab.vue', () => { + let wrapper; + + const factory = (props = {}) => + mount( + { + components: { + StrategyLabel, + }, + render(h) { + return h(StrategyLabel, { props: this.$attrs, on: this.$listeners }, this.$slots.default); + }, + }, + { + propsData: { + ...DEFAULT_PROPS, + ...props, + }, + }, + ); + + describe('render', () => { + let strategyLabel; + + beforeEach(() => { + wrapper = factory({}); + strategyLabel = wrapper.findComponent(StrategyLabel); + }); + + it('should show the strategy label with parameters and scope', () => { + expect(strategyLabel.text()).toContain(DEFAULT_PROPS.name); + expect(strategyLabel.text()).toContain(DEFAULT_PROPS.parameters); + expect(strategyLabel.text()).toContain(DEFAULT_PROPS.scopes); + expect(strategyLabel.text()).toContain('All Users - parameters: scope1, scope2'); + }); + }); + + describe('without parameters', () => { + let strategyLabel; + + beforeEach(() => { + wrapper = factory({ parameters: null }); + strategyLabel = wrapper.findComponent(StrategyLabel); + }); + + it('should hide empty params and dash', () => { + expect(strategyLabel.text()).toContain(DEFAULT_PROPS.name); + expect(strategyLabel.text()).not.toContain(' - '); + expect(strategyLabel.text()).toContain('All Users: scope1, scope2'); + }); + }); +}); diff --git a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js index 22bac3fca15..d82081041d9 100644 --- a/spec/frontend/feature_highlight/feature_highlight_helper_spec.js +++ b/spec/frontend/feature_highlight/feature_highlight_helper_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { dismiss } from '~/feature_highlight/feature_highlight_helper'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import httpStatusCodes, { HTTP_STATUS_CREATED } from '~/lib/utils/http_status'; jest.mock('~/flash'); @@ -11,7 +11,7 @@ describe('feature highlight helper', () => { let mockAxios; const endpoint = '/-/callouts/dismiss'; const highlightId = '123'; - const { CREATED, INTERNAL_SERVER_ERROR } = httpStatusCodes; + const { INTERNAL_SERVER_ERROR } = httpStatusCodes; beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -22,7 +22,7 @@ describe('feature highlight helper', () => { }); it('calls persistent dismissal endpoint with highlightId', async () => { - mockAxios.onPost(endpoint, { feature_name: highlightId }).replyOnce(CREATED); + mockAxios.onPost(endpoint, { feature_name: highlightId }).replyOnce(HTTP_STATUS_CREATED); await expect(dismiss(endpoint, highlightId)).resolves.toEqual(expect.anything()); }); 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 91457f10bf8..ebed477fa2f 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 @@ -2,6 +2,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import RecentSearchesDropdownContent from '~/filtered_search/components/recent_searches_dropdown_content.vue'; import eventHub from '~/filtered_search/event_hub'; import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered_search_token_keys'; +import { TOKEN_TYPE_AUTHOR } from '~/vue_shared/components/filtered_search_bar/constants'; describe('Recent Searches Dropdown Content', () => { let wrapper; @@ -60,7 +61,7 @@ describe('Recent Searches Dropdown Content', () => { items: [ 'foo', 'author:@root label:~foo bar', - [{ type: 'author_username', value: { data: 'toby', operator: '=' } }], + [{ type: TOKEN_TYPE_AUTHOR, value: { data: 'toby', operator: '=' } }], ], isLocalStorageAvailable: true, }); diff --git a/spec/frontend/filtered_search/filtered_search_manager_spec.js b/spec/frontend/filtered_search/filtered_search_manager_spec.js index 5e68725c03e..26af7af701b 100644 --- a/spec/frontend/filtered_search/filtered_search_manager_spec.js +++ b/spec/frontend/filtered_search/filtered_search_manager_spec.js @@ -8,7 +8,7 @@ import IssuableFilteredSearchTokenKeys from '~/filtered_search/issuable_filtered import RecentSearchesRoot from '~/filtered_search/recent_searches_root'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; import RecentSearchesServiceError from '~/filtered_search/services/recent_searches_service_error'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { BACKSPACE_KEY_CODE, DELETE_KEY_CODE } from '~/lib/utils/keycodes'; import { visitUrl, getParameterByName } from '~/lib/utils/url_utility'; @@ -130,14 +130,14 @@ describe('Filtered Search Manager', () => { manager = new FilteredSearchManager({ page }); }); - it('should not instantiate Flash if an RecentSearchesServiceError is caught', () => { + it('should not show an alert if an RecentSearchesServiceError is caught', () => { jest .spyOn(RecentSearchesService.prototype, 'fetch') .mockImplementation(() => Promise.reject(new RecentSearchesServiceError())); manager.setup(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); 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 0e5c94edd05..28fcf0b7ec7 100644 --- a/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js +++ b/spec/frontend/filtered_search/filtered_search_visual_tokens_spec.js @@ -4,6 +4,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import waitForPromises from 'helpers/wait_for_promises'; import FilteredSearchVisualTokens from '~/filtered_search/filtered_search_visual_tokens'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; describe('Filtered Search Visual Tokens', () => { let mock; @@ -302,7 +303,7 @@ describe('Filtered Search Visual Tokens', () => { }); const token = tokensContainer.querySelector('.js-visual-token'); - expect(token.classList.contains('filtered-search-term')).toEqual(true); + expect(token.classList.contains(FILTERED_SEARCH_TERM)).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('search term'); expect(token.querySelector('.operator').innerText).toEqual('='); expect(token.querySelector('.value')).toEqual(null); @@ -430,7 +431,7 @@ describe('Filtered Search Visual Tokens', () => { subject.addSearchVisualToken('search term'); const token = tokensContainer.querySelector('.js-visual-token'); - expect(token.classList.contains('filtered-search-term')).toEqual(true); + expect(token.classList.contains(FILTERED_SEARCH_TERM)).toEqual(true); expect(token.querySelector('.name').innerText).toEqual('search term'); expect(token.querySelector('.value')).toEqual(null); }); diff --git a/spec/frontend/filtered_search/visual_token_value_spec.js b/spec/frontend/filtered_search/visual_token_value_spec.js index e52ffa7bd9f..43c10090739 100644 --- a/spec/frontend/filtered_search/visual_token_value_spec.js +++ b/spec/frontend/filtered_search/visual_token_value_spec.js @@ -5,7 +5,7 @@ import FilteredSearchSpecHelper from 'helpers/filtered_search_spec_helper'; import { TEST_HOST } from 'helpers/test_constants'; import DropdownUtils from '~/filtered_search/dropdown_utils'; import VisualTokenValue from '~/filtered_search/visual_token_value'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import AjaxCache from '~/lib/utils/ajax_cache'; import UsersCache from '~/lib/utils/users_cache'; @@ -61,7 +61,7 @@ describe('Filtered Search Visual Tokens', () => { }; await subject.updateUserTokenAppearance(tokenValueContainer, tokenValueElement, tokenValue); - expect(createFlash.mock.calls.length).toBe(0); + expect(createAlert).toHaveBeenCalledTimes(0); }); it('does nothing if user cannot be found', async () => { diff --git a/spec/frontend/fixtures/api_merge_requests.rb b/spec/frontend/fixtures/api_merge_requests.rb index fae1f4056fb..a71a41dc5c4 100644 --- a/spec/frontend/fixtures/api_merge_requests.rb +++ b/spec/frontend/fixtures/api_merge_requests.rb @@ -6,7 +6,6 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do include ApiHelpers include JavaScriptFixturesHelpers - let_it_be(:admin) { create(:admin, name: 'root') } let_it_be(:namespace) { create(:namespace, name: 'gitlab-test') } let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'lorem-ipsum') } let_it_be(:early_mrs) do @@ -14,21 +13,22 @@ RSpec.describe API::MergeRequests, '(JavaScript fixtures)', type: :request do end let_it_be(:mr) { create(:merge_request, source_project: project) } + let_it_be(:user) { project.owner } it 'api/merge_requests/get.json' do - get api("/projects/#{project.id}/merge_requests", admin) + get api("/projects/#{project.id}/merge_requests", user) expect(response).to be_successful end it 'api/merge_requests/versions.json' do - get api("/projects/#{project.id}/merge_requests/#{mr.iid}/versions", admin) + get api("/projects/#{project.id}/merge_requests/#{mr.iid}/versions", user) expect(response).to be_successful end it 'api/merge_requests/changes.json' do - get api("/projects/#{project.id}/merge_requests/#{mr.iid}/changes", admin) + get api("/projects/#{project.id}/merge_requests/#{mr.iid}/changes", user) expect(response).to be_successful end diff --git a/spec/frontend/fixtures/api_projects.rb b/spec/frontend/fixtures/api_projects.rb index b14f402a7b9..d1dfd223419 100644 --- a/spec/frontend/fixtures/api_projects.rb +++ b/spec/frontend/fixtures/api_projects.rb @@ -6,25 +6,25 @@ 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') } + let(:user) { project.owner } it 'api/projects/get.json' do - get api("/projects/#{project.id}", admin) + get api("/projects/#{project.id}", user) expect(response).to be_successful end it 'api/projects/get_empty.json' do - get api("/projects/#{project_empty.id}", admin) + get api("/projects/#{project_empty.id}", user) expect(response).to be_successful end it 'api/projects/branches/get.json' do - get api("/projects/#{project.id}/repository/branches/#{project.default_branch}", admin) + get api("/projects/#{project.id}/repository/branches/#{project.default_branch}", user) expect(response).to be_successful end diff --git a/spec/frontend/fixtures/environments.rb b/spec/frontend/fixtures/environments.rb new file mode 100644 index 00000000000..3ca5b50ac9c --- /dev/null +++ b/spec/frontend/fixtures/environments.rb @@ -0,0 +1,53 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Environments (JavaScript fixtures)', feature_category: :environment_management do + include ApiHelpers + include JavaScriptFixturesHelpers + include GraphqlHelpers + + let_it_be(:admin) { create(:admin, username: 'administrator', email: 'admin@example.gitlab.com') } + let_it_be(:group) { create(:group, path: 'environments-group') } + let_it_be(:project) { create(:project, :repository, group: group, path: 'environments-project') } + + let_it_be(:environment) { create(:environment, name: 'staging', project: project) } + + let_it_be(:pipeline) { create(:ci_pipeline, project: project) } + let_it_be(:build) { create(:ci_build, :success, pipeline: pipeline) } + + let(:user) { create(:user) } + let(:role) { :developer } + let_it_be(:deployment) do + create(:deployment, :success, environment: environment, deployable: nil) + end + + let_it_be(:deployment_success) do + create(:deployment, :success, environment: environment, deployable: build) + end + + let_it_be(:deployment_failed) do + create(:deployment, :failed, environment: environment, deployable: build) + end + + let_it_be(:deployment_running) do + create(:deployment, :running, environment: environment, deployable: build) + end + + describe GraphQL::Query, type: :request do + environment_details_query_path = 'environments/graphql/queries/environment_details.query.graphql' + + it "graphql/#{environment_details_query_path}.json" do + query = get_graphql_query_as_string(environment_details_query_path) + + post_graphql(query, current_user: admin, + variables: + { + projectFullPath: project.full_path, + environmentName: environment.name, + pageSize: 10 + }) + expect_graphql_errors_to_be_empty + end + end +end diff --git a/spec/frontend/fixtures/freeze_period.rb b/spec/frontend/fixtures/freeze_period.rb index 5aa466ef015..a1c7564d36e 100644 --- a/spec/frontend/fixtures/freeze_period.rb +++ b/spec/frontend/fixtures/freeze_period.rb @@ -13,15 +13,6 @@ RSpec.describe 'Freeze Periods (JavaScript fixtures)' do remove_repository(project) end - around do |example| - freeze_time do - # Mock time to sept 19 (intl. talk like a pirate day) - travel_to(Time.utc(2020, 9, 19)) - - example.run - end - end - describe API::FreezePeriods, '(JavaScript fixtures)', type: :request do include ApiHelpers diff --git a/spec/frontend/fixtures/releases.rb b/spec/frontend/fixtures/releases.rb index fc344472588..c7e3d8fe804 100644 --- a/spec/frontend/fixtures/releases.rb +++ b/spec/frontend/fixtures/releases.rb @@ -6,9 +6,9 @@ RSpec.describe 'Releases (JavaScript fixtures)' do include ApiHelpers include JavaScriptFixturesHelpers - let_it_be(:admin) { create(:admin, username: 'administrator', email: 'admin@example.gitlab.com') } let_it_be(:namespace) { create(:namespace, path: 'releases-namespace') } let_it_be(:project) { create(:project, :repository, namespace: namespace, path: 'releases-project') } + let_it_be(:user) { create(:user, email: 'user@example.gitlab.com', username: 'user1') } let_it_be(:milestone_12_3) do create(:milestone, @@ -52,7 +52,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do project: project, tag: 'v1.1', name: 'The first release', - author: admin, + author: user, description: 'Best. Release. **Ever.** :rocket:', created_at: Time.zone.parse('2018-12-3'), released_at: Time.zone.parse('2018-12-10')) @@ -105,19 +105,23 @@ RSpec.describe 'Releases (JavaScript fixtures)' do project: project, tag: 'v1.2', name: 'The second release', - author: admin, + author: user, description: 'An okay release :shrug:', created_at: Time.zone.parse('2019-01-03'), released_at: Time.zone.parse('2019-01-10')) end + before do + project.add_owner(user) + end + after(:all) do remove_repository(project) end describe API::Releases, type: :request do it 'api/releases/release.json' do - get api("/projects/#{project.id}/releases/#{release.tag}", admin) + get api("/projects/#{project.id}/releases/#{release.tag}", user) expect(response).to be_successful end @@ -133,7 +137,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do it "graphql/#{all_releases_query_path}.json" do query = get_graphql_query_as_string(all_releases_query_path) - post_graphql(query, current_user: admin, variables: { fullPath: project.full_path }) + post_graphql(query, current_user: user, variables: { fullPath: project.full_path }) expect_graphql_errors_to_be_empty expect(graphql_data_at(:project, :releases)).to be_present @@ -142,7 +146,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do it "graphql/#{one_release_query_path}.json" do query = get_graphql_query_as_string(one_release_query_path) - post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, tagName: release.tag }) expect_graphql_errors_to_be_empty expect(graphql_data_at(:project, :release)).to be_present @@ -151,7 +155,7 @@ RSpec.describe 'Releases (JavaScript fixtures)' do it "graphql/#{one_release_for_editing_query_path}.json" do query = get_graphql_query_as_string(one_release_for_editing_query_path) - post_graphql(query, current_user: admin, variables: { fullPath: project.full_path, tagName: release.tag }) + post_graphql(query, current_user: user, variables: { fullPath: project.full_path, tagName: release.tag }) expect_graphql_errors_to_be_empty expect(graphql_data_at(:project, :release)).to be_present diff --git a/spec/frontend/fixtures/runner_instructions.rb b/spec/frontend/fixtures/runner_instructions.rb new file mode 100644 index 00000000000..90a01c37479 --- /dev/null +++ b/spec/frontend/fixtures/runner_instructions.rb @@ -0,0 +1,43 @@ +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe 'Runner Instructions (JavaScript fixtures)', feature_category: :runner do + include ApiHelpers + include JavaScriptFixturesHelpers + include GraphqlHelpers + + query_path = 'vue_shared/components/runner_instructions/graphql/queries' + + describe GraphQL::Query do + describe 'get_runner_platforms.query.graphql', type: :request do + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}/get_runner_platforms.query.graphql") + end + + it 'graphql/runner_instructions/get_runner_platforms.query.graphql.json' do + post_graphql(query) + + expect_graphql_errors_to_be_empty + end + end + + describe 'get_runner_setup.query.graphql', type: :request do + let_it_be(:query) do + get_graphql_query_as_string("#{query_path}/get_runner_setup.query.graphql") + end + + it 'graphql/runner_instructions/get_runner_setup.query.graphql.json' do + post_graphql(query, variables: { platform: 'linux', architecture: 'amd64' }) + + expect_graphql_errors_to_be_empty + end + + it 'graphql/runner_instructions/get_runner_setup.query.graphql.windows.json' do + post_graphql(query, variables: { platform: 'windows', architecture: 'amd64' }) + + expect_graphql_errors_to_be_empty + end + end + end +end diff --git a/spec/frontend/fixtures/tabs.rb b/spec/frontend/fixtures/tabs.rb index 697ff1c7c20..57ecb32e289 100644 --- a/spec/frontend/fixtures/tabs.rb +++ b/spec/frontend/fixtures/tabs.rb @@ -11,14 +11,14 @@ RSpec.describe 'GlTabsBehavior', '(JavaScript fixtures)', type: :helper do it 'tabs/tabs.html' do tabs = gl_tabs_nav({ data: { testid: 'tabs' } }) do gl_tab_link_to('Foo', '#foo', item_active: true, data: { testid: 'foo-tab' }) + - gl_tab_link_to('Bar', '#bar', item_active: false, data: { testid: 'bar-tab' }) + - gl_tab_link_to('Qux', '#qux', item_active: false, data: { testid: 'qux-tab' }) + gl_tab_link_to('Bar', '#bar', item_active: false, data: { testid: 'bar-tab' }) + + gl_tab_link_to('Qux', '#qux', item_active: false, data: { testid: 'qux-tab' }) end panels = content_tag(:div, class: 'tab-content') do content_tag(:div, 'Foo', { id: 'foo', class: 'tab-pane active', data: { testid: 'foo-panel' } }) + - content_tag(:div, 'Bar', { id: 'bar', class: 'tab-pane', data: { testid: 'bar-panel' } }) + - content_tag(:div, 'Qux', { id: 'qux', class: 'tab-pane', data: { testid: 'qux-panel' } }) + content_tag(:div, 'Bar', { id: 'bar', class: 'tab-pane', data: { testid: 'bar-panel' } }) + + content_tag(:div, 'Qux', { id: 'qux', class: 'tab-pane', data: { testid: 'qux-panel' } }) end @tabs = tabs + panels diff --git a/spec/frontend/flash_spec.js b/spec/frontend/flash_spec.js index a105b0b165c..ade36cd1637 100644 --- a/spec/frontend/flash_spec.js +++ b/spec/frontend/flash_spec.js @@ -12,6 +12,9 @@ import createFlash, { jest.mock('@sentry/browser'); describe('Flash', () => { + const findTextContent = (containerSelector = '.flash-container') => + document.querySelector(containerSelector).textContent.replace(/\s+/g, ' ').trim(); + describe('hideFlash', () => { let el; @@ -99,7 +102,7 @@ describe('Flash', () => { it('adds alert element into the document by default', () => { alert = createAlert({ message: mockMessage }); - expect(document.querySelector('.flash-container').textContent.trim()).toBe(mockMessage); + expect(findTextContent()).toBe(mockMessage); expect(document.querySelector('.flash-container .gl-alert')).not.toBeNull(); }); @@ -202,8 +205,7 @@ describe('Flash', () => { message: mockMessage, }); - const text = document.querySelector('.flash-container').textContent.trim(); - expect(text).toBe(`${mockTitle} ${mockMessage}`); + expect(findTextContent()).toBe(`${mockTitle} ${mockMessage}`); }); }); @@ -319,6 +321,22 @@ describe('Flash', () => { }); }); }); + + describe('when called multiple times', () => { + it('clears previous alerts', () => { + createAlert({ message: 'message 1' }); + createAlert({ message: 'message 2' }); + + expect(findTextContent()).toBe('message 2'); + }); + + it('preserves alerts when `preservePrevious` is true', () => { + createAlert({ message: 'message 1' }); + createAlert({ message: 'message 2', preservePrevious: true }); + + expect(findTextContent()).toBe('message 1 message 2'); + }); + }); }); }); diff --git a/spec/frontend/gfm_auto_complete_spec.js b/spec/frontend/gfm_auto_complete_spec.js index 68225f39c66..eeef92d4183 100644 --- a/spec/frontend/gfm_auto_complete_spec.js +++ b/spec/frontend/gfm_auto_complete_spec.js @@ -772,6 +772,7 @@ describe('GfmAutoComplete', () => { input | output ${'~'} | ${unassignedLabels} ${'/label ~'} | ${unassignedLabels} + ${'/labels ~'} | ${unassignedLabels} ${'/relabel ~'} | ${unassignedLabels} ${'/unlabel ~'} | ${[]} `('$input shows $output.length labels', expectLabels); @@ -786,6 +787,7 @@ describe('GfmAutoComplete', () => { input | output ${'~'} | ${allLabels} ${'/label ~'} | ${unassignedLabels} + ${'/labels ~'} | ${unassignedLabels} ${'/relabel ~'} | ${allLabels} ${'/unlabel ~'} | ${assignedLabels} `('$input shows $output.length labels', expectLabels); @@ -800,6 +802,7 @@ describe('GfmAutoComplete', () => { input | output ${'~'} | ${assignedLabels} ${'/label ~'} | ${[]} + ${'/labels ~'} | ${[]} ${'/relabel ~'} | ${assignedLabels} ${'/unlabel ~'} | ${assignedLabels} `('$input shows $output.length labels', expectLabels); diff --git a/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js b/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js new file mode 100644 index 00000000000..f1ed32a5f79 --- /dev/null +++ b/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_modal_spec.js @@ -0,0 +1,202 @@ +import { GlModal, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import { sprintf } from '~/locale'; +import SecurityPatchUpgradeAlertModal from '~/gitlab_version_check/components/security_patch_upgrade_alert_modal.vue'; +import * as utils from '~/gitlab_version_check/utils'; +import { + UPGRADE_DOCS_URL, + ABOUT_RELEASES_PAGE, + TRACKING_ACTIONS, + TRACKING_LABELS, +} from '~/gitlab_version_check/constants'; + +describe('SecurityPatchUpgradeAlertModal', () => { + let wrapper; + let trackingSpy; + + const defaultProps = { + currentVersion: '11.1.1', + }; + + const createComponent = (props = {}) => { + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + + wrapper = shallowMountExtended(SecurityPatchUpgradeAlertModal, { + propsData: { + ...defaultProps, + ...props, + }, + stubs: { + GlModal, + GlSprintf, + }, + }); + }; + + afterEach(() => { + unmockTracking(); + }); + + const expectDispatchedTracking = (action, label) => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, action, { + label, + property: defaultProps.currentVersion, + }); + }; + + const findGlModal = () => wrapper.findComponent(GlModal); + const findGlModalTitle = () => wrapper.findByTestId('alert-modal-title'); + const findGlModalBody = () => wrapper.findByTestId('alert-modal-body'); + const findGlModalDetails = () => wrapper.findByTestId('alert-modal-details'); + const findGlLink = () => wrapper.findComponent(GlLink); + const findGlRemindButton = () => wrapper.findByTestId('alert-modal-remind-button'); + const findGlUpgradeButton = () => wrapper.findByTestId('alert-modal-upgrade-button'); + + describe('template defaults', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders visible critical security alert modal', () => { + expect(findGlModal().props('visible')).toBe(true); + }); + + it('renders the modal title correctly', () => { + expect(findGlModalTitle().text()).toBe(wrapper.vm.$options.i18n.modalTitle); + }); + + it('renders modal body without suggested versions', () => { + expect(findGlModalBody().text()).toBe( + sprintf(wrapper.vm.$options.i18n.modalBodyNoStableVersions, { + currentVersion: defaultProps.currentVersion, + }), + ); + }); + + it('does not render modal details', () => { + expect(findGlModalDetails().exists()).toBe(false); + }); + + it(`tracks render ${TRACKING_LABELS.MODAL} correctly`, () => { + expectDispatchedTracking(TRACKING_ACTIONS.RENDER, TRACKING_LABELS.MODAL); + }); + + it(`tracks click ${TRACKING_LABELS.DISMISS} when close button clicked`, async () => { + await findGlModal().vm.$emit('close'); + + expectDispatchedTracking(TRACKING_ACTIONS.CLICK_BUTTON, TRACKING_LABELS.DISMISS); + }); + + describe('Learn more link', () => { + it('renders with correct text and link', () => { + expect(findGlLink().text()).toBe(wrapper.vm.$options.i18n.learnMore); + expect(findGlLink().attributes('href')).toBe(ABOUT_RELEASES_PAGE); + }); + + it(`tracks click ${TRACKING_LABELS.LEARN_MORE_LINK} when clicked`, async () => { + await findGlLink().vm.$emit('click'); + + expectDispatchedTracking(TRACKING_ACTIONS.CLICK_LINK, TRACKING_LABELS.LEARN_MORE_LINK); + }); + }); + + describe('Remind me button', () => { + beforeEach(() => { + wrapper.vm.$refs.alertModal.hide = jest.fn(); + }); + + it('renders with correct text', () => { + expect(findGlRemindButton().text()).toBe(wrapper.vm.$options.i18n.secondaryButtonText); + }); + + it(`tracks click ${TRACKING_LABELS.REMIND_ME_BTN} when clicked`, async () => { + await findGlRemindButton().vm.$emit('click'); + + expectDispatchedTracking(TRACKING_ACTIONS.CLICK_BUTTON, TRACKING_LABELS.REMIND_ME_BTN); + }); + + it('calls setHideAlertModalCookie with the currentVersion when clicked', async () => { + jest.spyOn(utils, 'setHideAlertModalCookie'); + await findGlRemindButton().vm.$emit('click'); + + expect(utils.setHideAlertModalCookie).toHaveBeenCalledWith(defaultProps.currentVersion); + }); + + it('hides the modal', async () => { + await findGlRemindButton().vm.$emit('click'); + + expect(wrapper.vm.$refs.alertModal.hide).toHaveBeenCalled(); + }); + }); + + describe('Upgrade button', () => { + it('renders with correct text and link', () => { + expect(findGlUpgradeButton().text()).toBe(wrapper.vm.$options.i18n.primaryButtonText); + expect(findGlUpgradeButton().attributes('href')).toBe(UPGRADE_DOCS_URL); + }); + + it(`tracks click ${TRACKING_LABELS.UPGRADE_BTN_LINK} when clicked`, async () => { + await findGlUpgradeButton().vm.$emit('click'); + + expectDispatchedTracking(TRACKING_ACTIONS.CLICK_LINK, TRACKING_LABELS.UPGRADE_BTN_LINK); + }); + + it('calls setHideAlertModalCookie with the currentVersion when clicked', async () => { + jest.spyOn(utils, 'setHideAlertModalCookie'); + await findGlUpgradeButton().vm.$emit('click'); + + expect(utils.setHideAlertModalCookie).toHaveBeenCalledWith(defaultProps.currentVersion); + }); + }); + }); + + describe('template with latestStableVersions', () => { + const latestStableVersions = ['88.8.3', '89.9.9', '90.0.0']; + + beforeEach(() => { + createComponent({ latestStableVersions }); + }); + + it('renders modal body with suggested versions', () => { + expect(findGlModalBody().text()).toBe( + sprintf(wrapper.vm.$options.i18n.modalBodyStableVersions, { + currentVersion: defaultProps.currentVersion, + latestStableVersions: latestStableVersions.join(', '), + }), + ); + }); + }); + + describe('template with details', () => { + const details = 'This is some details about the upgrade'; + + beforeEach(() => { + createComponent({ details }); + }); + + it('renders modal details', () => { + expect(findGlModalDetails().text()).toBe( + sprintf(wrapper.vm.$options.i18n.modalDetails, { details }), + ); + }); + }); + + describe('when modal is hidden by cookie', () => { + beforeEach(() => { + jest.spyOn(utils, 'getHideAlertModalCookie').mockReturnValue(true); + createComponent(); + }); + + it('renders modal with visibility false', () => { + expect(findGlModal().props('visible')).toBe(false); + }); + + it(`does not track render ${TRACKING_LABELS.MODAL} correctly`, () => { + expect(trackingSpy).not.toHaveBeenCalledWith(undefined, TRACKING_ACTIONS.RENDER, { + label: TRACKING_LABELS.MODAL, + property: defaultProps.currentVersion, + }); + }); + }); +}); diff --git a/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_spec.js b/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_spec.js new file mode 100644 index 00000000000..665dacd5c47 --- /dev/null +++ b/spec/frontend/gitlab_version_check/components/security_patch_upgrade_alert_spec.js @@ -0,0 +1,84 @@ +import { GlAlert, GlButton, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; +import SecurityPatchUpgradeAlert from '~/gitlab_version_check/components/security_patch_upgrade_alert.vue'; +import { UPGRADE_DOCS_URL, ABOUT_RELEASES_PAGE } from '~/gitlab_version_check/constants'; + +describe('SecurityPatchUpgradeAlert', () => { + let wrapper; + let trackingSpy; + + const defaultProps = { + currentVersion: '99.9', + }; + + const createComponent = () => { + trackingSpy = mockTracking(undefined, undefined, jest.spyOn); + + wrapper = shallowMount(SecurityPatchUpgradeAlert, { + propsData: { + ...defaultProps, + }, + stubs: { + GlAlert, + GlSprintf, + }, + }); + }; + + afterEach(() => { + unmockTracking(); + }); + + const findGlAlert = () => wrapper.findComponent(GlAlert); + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlLink = () => wrapper.findComponent(GlLink); + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders non-dismissible GlAlert with version information', () => { + expect(findGlAlert().text()).toContain( + `You are currently on version ${defaultProps.currentVersion}.`, + ); + expect(findGlAlert().props('dismissible')).toBe(false); + }); + + it('tracks render security_patch_upgrade_alert correctly', () => { + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'render', { + label: 'security_patch_upgrade_alert', + property: defaultProps.currentVersion, + }); + }); + + it('renders GlLink with correct text and link', () => { + expect(findGlLink().text()).toBe('Learn more about this critical security release.'); + expect(findGlLink().attributes('href')).toBe(ABOUT_RELEASES_PAGE); + }); + + it('tracks click security_patch_upgrade_alert_learn_more when link is clicked', async () => { + await findGlLink().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { + label: 'security_patch_upgrade_alert_learn_more', + property: defaultProps.currentVersion, + }); + }); + + it('renders GlButton with correct text and link', () => { + expect(findGlButton().text()).toBe('Upgrade now'); + expect(findGlButton().attributes('href')).toBe(UPGRADE_DOCS_URL); + }); + + it('tracks click security_patch_upgrade_alert_upgrade_now when button is clicked', async () => { + await findGlButton().vm.$emit('click'); + + expect(trackingSpy).toHaveBeenCalledWith(undefined, 'click_link', { + label: 'security_patch_upgrade_alert_upgrade_now', + property: defaultProps.currentVersion, + }); + }); + }); +}); diff --git a/spec/frontend/gitlab_version_check/index_spec.js b/spec/frontend/gitlab_version_check/index_spec.js index 8a11ff48bf2..92bc103cede 100644 --- a/spec/frontend/gitlab_version_check/index_spec.js +++ b/spec/frontend/gitlab_version_check/index_spec.js @@ -1,116 +1,52 @@ -import Vue from 'vue'; -import * as Sentry from '@sentry/browser'; -import MockAdapter from 'axios-mock-adapter'; -import axios from '~/lib/utils/axios_utils'; +import { createWrapper } from '@vue/test-utils'; import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import initGitlabVersionCheck from '~/gitlab_version_check'; +import { + VERSION_CHECK_BADGE_NO_PROP_FIXTURE, + VERSION_CHECK_BADGE_NO_SEVERITY_FIXTURE, + VERSION_CHECK_BADGE_FIXTURE, + VERSION_CHECK_BADGE_FINDER, + VERSION_BADGE_TEXT, + SECURITY_PATCH_FIXTURE, + SECURITY_PATCH_FINDER, + SECURITY_PATCH_TEXT, + SECURITY_MODAL_FIXTURE, + SECURITY_MODAL_FINDER, + SECURITY_MODAL_TEXT, +} from './mock_data'; describe('initGitlabVersionCheck', () => { - let originalGon; - let mock; - let vueApps; + let wrapper; - const defaultResponse = { - code: 200, - res: { severity: 'success' }, - }; - - const dummyGon = { - relative_url_root: '/', - }; - - const createApp = async (mockResponse, htmlClass) => { - originalGon = window.gon; - - const response = { - ...defaultResponse, - ...mockResponse, - }; - - mock = new MockAdapter(axios); - mock.onGet().replyOnce(response.code, response.res); - - setHTMLFixture(`<div class="${htmlClass}"></div>`); - - vueApps = await initGitlabVersionCheck(); + const createApp = (fixture) => { + setHTMLFixture(fixture); + initGitlabVersionCheck(); + wrapper = createWrapper(document.body); }; afterEach(() => { - mock.restore(); - window.gon = originalGon; resetHTMLFixture(); }); - describe('with no .js-gitlab-version-check-badge elements', () => { - beforeEach(async () => { - await createApp(); - }); - - it('does not make axios GET request', () => { - expect(mock.history.get.length).toBe(0); - }); - - it('does not render the Version Check Badge', () => { - expect(vueApps).toBeNull(); - }); - }); - - describe('with .js-gitlab-version-check-badge element but API errors', () => { - beforeEach(async () => { - jest.spyOn(Sentry, 'captureException'); - await createApp({ code: 500, res: null }, 'js-gitlab-version-check-badge'); - }); - - it('does make axios GET request', () => { - expect(mock.history.get.length).toBe(1); - expect(mock.history.get[0].url).toContain('/admin/version_check.json'); - }); - - it('logs error to Sentry', () => { - expect(Sentry.captureException).toHaveBeenCalled(); - }); - - it('does not render the Version Check Badge', () => { - expect(vueApps).toBeNull(); - }); - }); - - describe('with .js-gitlab-version-check-badge element and successful API call', () => { - beforeEach(async () => { - await createApp({}, 'js-gitlab-version-check-badge'); - }); - - it('does make axios GET request', () => { - expect(mock.history.get.length).toBe(1); - expect(mock.history.get[0].url).toContain('/admin/version_check.json'); - }); - - it('does render the Version Check Badge', () => { - expect(vueApps).toHaveLength(1); - expect(vueApps[0]).toBeInstanceOf(Vue); - }); - }); - describe.each` - root | description - ${'/'} | ${'not used (uses its own (sub)domain)'} - ${'/gitlab'} | ${'custom path'} - ${'/service/gitlab'} | ${'custom path with 2 depth'} - `('path for version_check.json', ({ root, description }) => { - describe(`when relative url is ${description}: ${root}`, () => { - beforeEach(async () => { - originalGon = window.gon; - window.gon = { ...dummyGon }; - window.gon.relative_url_root = root; - await createApp({}, 'js-gitlab-version-check-badge'); - }); - - it('reflects the relative url setting', () => { - expect(mock.history.get.length).toBe(1); - - const pathRegex = new RegExp(`^${root}`); - expect(mock.history.get[0].url).toMatch(pathRegex); - }); + description | fixture | finders | componentTexts + ${'with no version check elements'} | ${'<div></div>'} | ${[]} | ${[]} + ${'with version check badge el but no prop data'} | ${VERSION_CHECK_BADGE_NO_PROP_FIXTURE} | ${[VERSION_CHECK_BADGE_FINDER]} | ${[undefined]} + ${'with version check badge el but no severity data'} | ${VERSION_CHECK_BADGE_NO_SEVERITY_FIXTURE} | ${[VERSION_CHECK_BADGE_FINDER]} | ${[undefined]} + ${'with version check badge el and version data'} | ${VERSION_CHECK_BADGE_FIXTURE} | ${[VERSION_CHECK_BADGE_FINDER]} | ${[VERSION_BADGE_TEXT]} + ${'with security patch el'} | ${SECURITY_PATCH_FIXTURE} | ${[SECURITY_PATCH_FINDER]} | ${[SECURITY_PATCH_TEXT]} + ${'with security patch and version badge els'} | ${`${SECURITY_PATCH_FIXTURE}${VERSION_CHECK_BADGE_FIXTURE}`} | ${[SECURITY_PATCH_FINDER, VERSION_CHECK_BADGE_FINDER]} | ${[SECURITY_PATCH_TEXT, VERSION_BADGE_TEXT]} + ${'with security modal el'} | ${SECURITY_MODAL_FIXTURE} | ${[SECURITY_MODAL_FINDER]} | ${[SECURITY_MODAL_TEXT]} + ${'with security modal, security patch, and version badge els'} | ${`${SECURITY_PATCH_FIXTURE}${SECURITY_MODAL_FIXTURE}${VERSION_CHECK_BADGE_FIXTURE}`} | ${[SECURITY_PATCH_FINDER, SECURITY_MODAL_FINDER, VERSION_CHECK_BADGE_FINDER]} | ${[SECURITY_PATCH_TEXT, SECURITY_MODAL_TEXT, VERSION_BADGE_TEXT]} + `('$description', ({ fixture, finders, componentTexts }) => { + beforeEach(() => { + createApp(fixture); + }); + + it(`correctly renders the Version Check Components`, () => { + const renderedComponentTexts = finders.map((f) => wrapper.find(f)?.element?.innerText.trim()); + + expect(renderedComponentTexts).toStrictEqual(componentTexts); }); }); }); diff --git a/spec/frontend/gitlab_version_check/mock_data.js b/spec/frontend/gitlab_version_check/mock_data.js new file mode 100644 index 00000000000..707d45550eb --- /dev/null +++ b/spec/frontend/gitlab_version_check/mock_data.js @@ -0,0 +1,22 @@ +export const VERSION_CHECK_BADGE_NO_PROP_FIXTURE = + '<div class="js-gitlab-version-check-badge"></div>'; + +export const VERSION_CHECK_BADGE_NO_SEVERITY_FIXTURE = `<div class="js-gitlab-version-check-badge" data-version='{ "size": "sm" }'></div>`; + +export const VERSION_CHECK_BADGE_FIXTURE = `<div class="js-gitlab-version-check-badge" data-version='{ "severity": "success" }'></div>`; + +export const VERSION_CHECK_BADGE_FINDER = '[data-testid="badge-click-wrapper"]'; + +export const VERSION_BADGE_TEXT = 'Up to date'; + +export const SECURITY_PATCH_FIXTURE = `<div id="js-security-patch-upgrade-alert" data-current-version="15.1"></div>`; + +export const SECURITY_PATCH_FINDER = 'h2'; + +export const SECURITY_PATCH_TEXT = 'Critical security upgrade available'; + +export const SECURITY_MODAL_FIXTURE = `<div id="js-security-patch-upgrade-alert-modal" data-current-version="15.1" data-version='{ "details": "test details", "latest-stable-versions": "[]" }'></div>`; + +export const SECURITY_MODAL_FINDER = '[data-testid="alert-modal-title"]'; + +export const SECURITY_MODAL_TEXT = 'Important notice - Critical security release'; diff --git a/spec/frontend/gitlab_version_check/utils_spec.js b/spec/frontend/gitlab_version_check/utils_spec.js new file mode 100644 index 00000000000..6126d88dfec --- /dev/null +++ b/spec/frontend/gitlab_version_check/utils_spec.js @@ -0,0 +1,35 @@ +import { parseBoolean, getCookie, setCookie } from '~/lib/utils/common_utils'; +import { getHideAlertModalCookie, setHideAlertModalCookie } from '~/gitlab_version_check/utils'; +import { COOKIE_EXPIRATION, COOKIE_SUFFIX } from '~/gitlab_version_check/constants'; + +jest.mock('~/lib/utils/common_utils', () => ({ + parseBoolean: jest.fn().mockReturnValue(true), + getCookie: jest.fn().mockReturnValue('true'), + setCookie: jest.fn(), +})); + +describe('GitLab Version Check Utils', () => { + describe('setHideAlertModalCookie', () => { + it('properly generates a key based on the currentVersion and sets Cookie to `true`', () => { + const currentVersion = '99.9.9'; + + setHideAlertModalCookie(currentVersion); + + expect(setCookie).toHaveBeenCalledWith(`${currentVersion}${COOKIE_SUFFIX}`, true, { + expires: COOKIE_EXPIRATION, + }); + }); + }); + + describe('getHideAlertModalCookie', () => { + it('properly generates a key based on the currentVersion, fetches said Cooke, and parsesBoolean it', () => { + const currentVersion = '99.9.9'; + + const res = getHideAlertModalCookie(currentVersion); + + expect(getCookie).toHaveBeenCalledWith(`${currentVersion}${COOKIE_SUFFIX}`); + expect(parseBoolean).toHaveBeenCalledWith('true'); + expect(res).toBe(true); + }); + }); +}); diff --git a/spec/frontend/groups/components/app_spec.js b/spec/frontend/groups/components/app_spec.js index 091ec17d58e..140609161d4 100644 --- a/spec/frontend/groups/components/app_spec.js +++ b/spec/frontend/groups/components/app_spec.js @@ -1,7 +1,7 @@ import { GlModal, GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import Vue, { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import appComponent from '~/groups/components/app.vue'; @@ -10,8 +10,6 @@ import groupItemComponent from '~/groups/components/group_item.vue'; import eventHub from '~/groups/event_hub'; import GroupsService from '~/groups/service/groups_service'; import GroupsStore from '~/groups/store/groups_store'; -import EmptyState from '~/groups/components/empty_state.vue'; -import GroupsComponent from '~/groups/components/groups.vue'; import axios from '~/lib/utils/axios_utils'; import * as urlUtilities from '~/lib/utils/url_utility'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -43,7 +41,7 @@ describe('AppComponent', () => { const createShallowComponent = ({ propsData = {} } = {}) => { store.state.pageInfo = mockPageInfo; - wrapper = shallowMount(appComponent, { + wrapper = shallowMountExtended(appComponent, { propsData: { store, service, @@ -51,6 +49,9 @@ describe('AppComponent', () => { containerId: 'js-groups-tree', ...propsData, }, + scopedSlots: { + 'empty-state': '<div data-testid="empty-state" />', + }, mocks: { $toast, }, @@ -68,6 +69,7 @@ describe('AppComponent', () => { mock.onGet('/dashboard/groups.json').reply(200, mockGroups); Vue.component('GroupFolder', groupFolderComponent); Vue.component('GroupItem', groupItemComponent); + setWindowLocation('?filter=foobar'); document.body.innerHTML = ` <div id="js-groups-tree"> @@ -149,13 +151,13 @@ describe('AppComponent', () => { expect(vm.fetchGroups).toHaveBeenCalledWith({ page: null, - filterGroupsBy: null, + filterGroupsBy: 'foobar', sortBy: null, updatePagination: true, archived: null, }); return fetchPromise.then(() => { - expect(vm.updateGroups).toHaveBeenCalled(); + expect(vm.updateGroups).toHaveBeenCalledWith(mockSearchedGroups, true); }); }); }); @@ -375,32 +377,16 @@ describe('AppComponent', () => { expect(vm.store.setSearchedGroups).toHaveBeenCalledWith(mockGroups); }); - it('should set `isSearchEmpty` prop based on groups count and `filter` query param', () => { - setWindowLocation('?filter=foobar'); - createShallowComponent(); - - vm.updateGroups(mockGroups); - - expect(vm.isSearchEmpty).toBe(false); - - vm.updateGroups([]); - - expect(vm.isSearchEmpty).toBe(true); - }); - describe.each` - action | groups | fromSearch | shouldRenderEmptyState | searchEmpty - ${'subgroups_and_projects'} | ${[]} | ${false} | ${true} | ${false} - ${''} | ${[]} | ${false} | ${false} | ${false} - ${'subgroups_and_projects'} | ${mockGroups} | ${false} | ${false} | ${false} - ${'subgroups_and_projects'} | ${[]} | ${true} | ${false} | ${true} + groups | fromSearch | shouldRenderEmptyState | shouldRenderSearchEmptyState + ${[]} | ${false} | ${true} | ${false} + ${mockGroups} | ${false} | ${false} | ${false} + ${[]} | ${true} | ${false} | ${true} `( - 'when `action` is $action, `groups` is $groups, and `fromSearch` is $fromSearch', - ({ action, groups, fromSearch, shouldRenderEmptyState, searchEmpty }) => { + 'when `groups` is $groups, and `fromSearch` is $fromSearch', + ({ groups, fromSearch, shouldRenderEmptyState, shouldRenderSearchEmptyState }) => { it(`${shouldRenderEmptyState ? 'renders' : 'does not render'} empty state`, async () => { - createShallowComponent({ - propsData: { action, renderEmptyState: true }, - }); + createShallowComponent(); await waitForPromises(); @@ -408,28 +394,14 @@ describe('AppComponent', () => { await nextTick(); - expect(wrapper.findComponent(EmptyState).exists()).toBe(shouldRenderEmptyState); - expect(wrapper.findComponent(GroupsComponent).props('searchEmpty')).toBe(searchEmpty); + expect(wrapper.findByTestId('empty-state').exists()).toBe(shouldRenderEmptyState); + expect(wrapper.findByTestId('search-empty-state').exists()).toBe( + shouldRenderSearchEmptyState, + ); }); }, ); }); - - describe('when `action` is subgroups_and_projects, `groups` is [], `fromSearch` is `false`, and `renderEmptyState` is `false`', () => { - it('renders legacy empty state', async () => { - createShallowComponent({ - propsData: { action: 'subgroups_and_projects' }, - }); - - vm.updateGroups([], false); - - await nextTick(); - - expect( - document.querySelector('[data-testid="legacy-empty-state"]').classList.contains('hidden'), - ).toBe(false); - }); - }); }); describe('created', () => { diff --git a/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js new file mode 100644 index 00000000000..be61ffa92b4 --- /dev/null +++ b/spec/frontend/groups/components/empty_states/archived_projects_empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archived_projects_empty_state.vue'; + +let wrapper; + +const defaultProvide = { + newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', +}; + +const createComponent = () => { + wrapper = mountExtended(ArchivedProjectsEmptyState, { + provide: defaultProvide, + }); +}; + +describe('ArchivedProjectsEmptyState', () => { + it('renders empty state', () => { + createComponent(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: ArchivedProjectsEmptyState.i18n.title, + svgPath: defaultProvide.newProjectIllustration, + }); + }); +}); diff --git a/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js new file mode 100644 index 00000000000..c4ace1be1f3 --- /dev/null +++ b/spec/frontend/groups/components/empty_states/shared_projects_empty_state_spec.js @@ -0,0 +1,27 @@ +import { GlEmptyState } from '@gitlab/ui'; + +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_projects_empty_state.vue'; + +let wrapper; + +const defaultProvide = { + newProjectIllustration: '/assets/illustrations/project-create-new-sm.svg', +}; + +const createComponent = () => { + wrapper = mountExtended(SharedProjectsEmptyState, { + provide: defaultProvide, + }); +}; + +describe('SharedProjectsEmptyState', () => { + it('renders empty state', () => { + createComponent(); + + expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ + title: SharedProjectsEmptyState.i18n.title, + svgPath: defaultProvide.newProjectIllustration, + }); + }); +}); diff --git a/spec/frontend/groups/components/empty_state_spec.js b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js index fbeaa32b1ec..75edc602fbf 100644 --- a/spec/frontend/groups/components/empty_state_spec.js +++ b/spec/frontend/groups/components/empty_states/subgroups_and_projects_empty_state_spec.js @@ -1,7 +1,7 @@ import { GlEmptyState } from '@gitlab/ui'; import { mountExtended } from 'jest/__helpers__/vue_test_utils_helper'; -import EmptyState from '~/groups/components/empty_state.vue'; +import SubgroupsAndProjectsEmptyState from '~/groups/components/empty_states/subgroups_and_projects_empty_state.vue'; let wrapper; @@ -16,7 +16,7 @@ const defaultProvide = { }; const createComponent = ({ provide = {} } = {}) => { - wrapper = mountExtended(EmptyState, { + wrapper = mountExtended(SubgroupsAndProjectsEmptyState, { provide: { ...defaultProvide, ...provide, @@ -30,18 +30,18 @@ afterEach(() => { const findNewSubgroupLink = () => wrapper.findByRole('link', { - name: new RegExp(EmptyState.i18n.withLinks.subgroup.title), + name: new RegExp(SubgroupsAndProjectsEmptyState.i18n.withLinks.subgroup.title), }); const findNewProjectLink = () => wrapper.findByRole('link', { - name: new RegExp(EmptyState.i18n.withLinks.project.title), + name: new RegExp(SubgroupsAndProjectsEmptyState.i18n.withLinks.project.title), }); const findNewSubgroupIllustration = () => - wrapper.findByRole('img', { name: EmptyState.i18n.withLinks.subgroup.title }); + wrapper.findByRole('img', { name: SubgroupsAndProjectsEmptyState.i18n.withLinks.subgroup.title }); const findNewProjectIllustration = () => - wrapper.findByRole('img', { name: EmptyState.i18n.withLinks.project.title }); + wrapper.findByRole('img', { name: SubgroupsAndProjectsEmptyState.i18n.withLinks.project.title }); -describe('EmptyState', () => { +describe('SubgroupsAndProjectsEmptyState', () => { describe('when user has permission to create a subgroup', () => { it('renders `Create new subgroup` link', () => { createComponent(); @@ -69,8 +69,8 @@ describe('EmptyState', () => { createComponent({ provide: { canCreateSubgroups: false, canCreateProjects: false } }); expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ - title: EmptyState.i18n.withoutLinks.title, - description: EmptyState.i18n.withoutLinks.description, + title: SubgroupsAndProjectsEmptyState.i18n.withoutLinks.title, + description: SubgroupsAndProjectsEmptyState.i18n.withoutLinks.description, svgPath: defaultProvide.emptySubgroupIllustration, }); }); diff --git a/spec/frontend/groups/components/group_name_and_path_spec.js b/spec/frontend/groups/components/group_name_and_path_spec.js index 823d2ed286a..9965b608f27 100644 --- a/spec/frontend/groups/components/group_name_and_path_spec.js +++ b/spec/frontend/groups/components/group_name_and_path_spec.js @@ -398,7 +398,7 @@ describe('GroupNameAndPath', () => { expect(findAlert().exists()).toBe(true); expect(findAlert().findByRole('link', { name: 'Learn more' }).attributes('href')).toBe( - helpPagePath('user/group/index', { + helpPagePath('user/group/manage', { anchor: 'change-a-groups-path', }), ); diff --git a/spec/frontend/groups/components/groups_spec.js b/spec/frontend/groups/components/groups_spec.js index 0cbb6cc8309..cae29a8f15a 100644 --- a/spec/frontend/groups/components/groups_spec.js +++ b/spec/frontend/groups/components/groups_spec.js @@ -16,7 +16,6 @@ describe('GroupsComponent', () => { const defaultPropsData = { groups: mockGroups, pageInfo: mockPageInfo, - searchEmpty: false, }; const createComponent = ({ propsData } = {}) => { @@ -69,14 +68,5 @@ describe('GroupsComponent', () => { expect(findPaginationLinks().exists()).toBe(true); expect(wrapper.findComponent(GlEmptyState).exists()).toBe(false); }); - - it('should render empty search message when `searchEmpty` is `true`', () => { - createComponent({ propsData: { searchEmpty: true } }); - - expect(wrapper.findComponent(GlEmptyState).props()).toMatchObject({ - title: GroupsComponent.i18n.emptyStateTitle, - description: GroupsComponent.i18n.emptyStateDescription, - }); - }); }); }); diff --git a/spec/frontend/groups/components/overview_tabs_spec.js b/spec/frontend/groups/components/overview_tabs_spec.js index b615679dcc5..d1ae2c4be17 100644 --- a/spec/frontend/groups/components/overview_tabs_spec.js +++ b/spec/frontend/groups/components/overview_tabs_spec.js @@ -1,11 +1,13 @@ import { GlSorting, GlSortingItem, GlTab } from '@gitlab/ui'; -import { nextTick } from 'vue'; -import { createLocalVue } from '@vue/test-utils'; +import Vue, { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import OverviewTabs from '~/groups/components/overview_tabs.vue'; import GroupsApp from '~/groups/components/app.vue'; import GroupFolderComponent from '~/groups/components/group_folder.vue'; +import SubgroupsAndProjectsEmptyState from '~/groups/components/empty_states/subgroups_and_projects_empty_state.vue'; +import SharedProjectsEmptyState from '~/groups/components/empty_states/shared_projects_empty_state.vue'; +import ArchivedProjectsEmptyState from '~/groups/components/empty_states/archived_projects_empty_state.vue'; import GroupsStore from '~/groups/store/groups_store'; import GroupsService from '~/groups/service/groups_service'; import { createRouter } from '~/groups/init_overview_tabs'; @@ -17,9 +19,9 @@ import { OVERVIEW_TABS_SORTING_ITEMS, } from '~/groups/constants'; import axios from '~/lib/utils/axios_utils'; +import waitForPromises from 'helpers/wait_for_promises'; -const localVue = createLocalVue(); -localVue.component('GroupFolder', GroupFolderComponent); +Vue.component('GroupFolder', GroupFolderComponent); const router = createRouter(); const [SORTING_ITEM_NAME, , SORTING_ITEM_UPDATED] = OVERVIEW_TABS_SORTING_ITEMS; @@ -57,7 +59,6 @@ describe('OverviewTabs', () => { ...defaultProvide, ...provide, }, - localVue, mocks: { $route: route, $router: routerMock }, }); @@ -71,6 +72,7 @@ describe('OverviewTabs', () => { beforeEach(() => { axiosMock = new AxiosMockAdapter(axios); + axiosMock.onGet({ data: [] }); }); afterEach(() => { @@ -78,7 +80,7 @@ describe('OverviewTabs', () => { axiosMock.restore(); }); - it('renders `Subgroups and projects` tab with `GroupsApp` component', async () => { + it('renders `Subgroups and projects` tab with `GroupsApp` component with correct empty state', async () => { await createComponent(); const tabPanel = findTabPanels().at(0); @@ -92,11 +94,14 @@ describe('OverviewTabs', () => { store: new GroupsStore({ showSchemaMarkup: true }), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SUBGROUPS_AND_PROJECTS]), hideProjects: false, - renderEmptyState: true, }); + + await waitForPromises(); + + expect(wrapper.findComponent(SubgroupsAndProjectsEmptyState).exists()).toBe(true); }); - it('renders `Shared projects` tab and renders `GroupsApp` component after clicking tab', async () => { + it('renders `Shared projects` tab and renders `GroupsApp` component with correct empty state after clicking tab', async () => { await createComponent(); const tabPanel = findTabPanels().at(1); @@ -113,13 +118,16 @@ describe('OverviewTabs', () => { store: new GroupsStore(), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_SHARED]), hideProjects: false, - renderEmptyState: false, }); expect(tabPanel.vm.$attrs.lazy).toBe(false); + + await waitForPromises(); + + expect(wrapper.findComponent(SharedProjectsEmptyState).exists()).toBe(true); }); - it('renders `Archived projects` tab and renders `GroupsApp` component after clicking tab', async () => { + it('renders `Archived projects` tab and renders `GroupsApp` component with correct empty state after clicking tab', async () => { await createComponent(); const tabPanel = findTabPanels().at(2); @@ -136,10 +144,13 @@ describe('OverviewTabs', () => { store: new GroupsStore(), service: new GroupsService(defaultProvide.endpoints[ACTIVE_TAB_ARCHIVED]), hideProjects: false, - renderEmptyState: false, }); expect(tabPanel.vm.$attrs.lazy).toBe(false); + + await waitForPromises(); + + expect(wrapper.findComponent(ArchivedProjectsEmptyState).exists()).toBe(true); }); it('sets `lazy` prop to `false` for initially active tab and `true` for all other tabs', async () => { diff --git a/spec/frontend/header_search/components/app_spec.js b/spec/frontend/header_search/components/app_spec.js index b0bfe2b45f0..c714c269ca0 100644 --- a/spec/frontend/header_search/components/app_spec.js +++ b/spec/frontend/header_search/components/app_spec.js @@ -180,7 +180,6 @@ describe('HeaderSearchApp', () => { findHeaderSearchInput().vm.$emit('keydown', new KeyboardEvent({ key: 27 })); await nextTick(); expect(findHeaderSearchDropdown().exists()).toBe(false); - // only one event emmited from findHeaderSearchInput().vm.$emit('click'); expect(wrapper.emitted().expandSearchBar.length).toBe(1); }); }); diff --git a/spec/frontend/ide/components/panes/right_spec.js b/spec/frontend/ide/components/panes/right_spec.js index b7349b8fed1..294f5eee863 100644 --- a/spec/frontend/ide/components/panes/right_spec.js +++ b/spec/frontend/ide/components/panes/right_spec.js @@ -3,16 +3,12 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import CollapsibleSidebar from '~/ide/components/panes/collapsible_sidebar.vue'; import RightPane from '~/ide/components/panes/right.vue'; -import SwitchEditorsView from '~/ide/components/switch_editors/switch_editors_view.vue'; import { rightSidebarViews } from '~/ide/constants'; import { createStore } from '~/ide/stores'; import extendStore from '~/ide/stores/extend'; -import { __ } from '~/locale'; Vue.use(Vuex); -const SWITCH_EDITORS_VIEW_NAME = 'switch-editors'; - describe('ide/components/panes/right.vue', () => { let wrapper; let store; @@ -45,7 +41,6 @@ describe('ide/components/panes/right.vue', () => { it('renders collapsible-sidebar', () => { expect(wrapper.findComponent(CollapsibleSidebar).props()).toMatchObject({ side: 'right', - initOpenView: SWITCH_EDITORS_VIEW_NAME, }); }); }); @@ -130,32 +125,4 @@ describe('ide/components/panes/right.vue', () => { ); }); }); - - describe('switch editors tab', () => { - beforeEach(() => { - createComponent(); - }); - - it.each` - desc | canUseNewWebIde | expectedShow - ${'is shown'} | ${true} | ${true} - ${'is not shown'} | ${false} | ${false} - `('with canUseNewWebIde=$canUseNewWebIde, $desc', async ({ canUseNewWebIde, expectedShow }) => { - Object.assign(store.state, { canUseNewWebIde }); - - await nextTick(); - - expect(wrapper.findComponent(CollapsibleSidebar).props('extensionTabs')).toEqual( - expect.arrayContaining([ - expect.objectContaining({ - show: expectedShow, - title: __('Switch editors'), - views: [ - { component: SwitchEditorsView, name: SWITCH_EDITORS_VIEW_NAME, keepAlive: true }, - ], - }), - ]), - ); - }); - }); }); diff --git a/spec/frontend/ide/components/pipelines/list_spec.js b/spec/frontend/ide/components/pipelines/list_spec.js index 545924c9c11..d82b97561f0 100644 --- a/spec/frontend/ide/components/pipelines/list_spec.js +++ b/spec/frontend/ide/components/pipelines/list_spec.js @@ -185,7 +185,7 @@ describe('IDE pipelines list', () => { }, ); - expect(wrapper.text()).toContain('Found errors in your .gitlab-ci.yml:'); + expect(wrapper.text()).toContain('Unable to create pipeline'); expect(wrapper.text()).toContain(yamlError); }); }); diff --git a/spec/frontend/ide/components/repo_editor_spec.js b/spec/frontend/ide/components/repo_editor_spec.js index 9921d8cba18..211fee31a9c 100644 --- a/spec/frontend/ide/components/repo_editor_spec.js +++ b/spec/frontend/ide/components/repo_editor_spec.js @@ -4,13 +4,17 @@ import { editor as monacoEditor, Range } from 'monaco-editor'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { shallowMount } from '@vue/test-utils'; -import '~/behaviors/markdown/render_gfm'; import waitForPromises from 'helpers/wait_for_promises'; import { stubPerformanceWebAPI } from 'helpers/performance'; import { exampleConfigs, exampleFiles } from 'jest/ide/lib/editorconfig/mock_data'; -import { EDITOR_CODE_INSTANCE_FN, EDITOR_DIFF_INSTANCE_FN } from '~/editor/constants'; +import { + EDITOR_CODE_INSTANCE_FN, + EDITOR_DIFF_INSTANCE_FN, + EXTENSION_CI_SCHEMA_FILE_NAME_MATCH, +} from '~/editor/constants'; import { EditorMarkdownExtension } from '~/editor/extensions/source_editor_markdown_ext'; import { EditorMarkdownPreviewExtension } from '~/editor/extensions/source_editor_markdown_livepreview_ext'; +import { CiSchemaExtension } from '~/editor/extensions/source_editor_ci_schema_ext'; import SourceEditor from '~/editor/source_editor'; import RepoEditor from '~/ide/components/repo_editor.vue'; import { leftSidebarViews, FILE_VIEW_MODE_PREVIEW, viewerTypes } from '~/ide/constants'; @@ -22,6 +26,9 @@ import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer import SourceEditorInstance from '~/editor/source_editor_instance'; import { file } from '../helpers'; +jest.mock('~/behaviors/markdown/render_gfm'); +jest.mock('~/editor/extensions/source_editor_ci_schema_ext'); + const PREVIEW_MARKDOWN_PATH = '/foo/bar/preview_markdown'; const CURRENT_PROJECT_ID = 'gitlab-org/gitlab'; @@ -46,6 +53,12 @@ const dummyFile = { tempFile: true, active: true, }, + ciConfig: { + ...file(EXTENSION_CI_SCHEMA_FILE_NAME_MATCH), + content: '', + tempFile: true, + active: true, + }, empty: { ...file('empty'), tempFile: false, @@ -101,6 +114,7 @@ describe('RepoEditor', () => { let createDiffInstanceSpy; let createModelSpy; let applyExtensionSpy; + let removeExtensionSpy; let extensionsStore; const waitForEditorSetup = () => @@ -108,7 +122,7 @@ describe('RepoEditor', () => { vm.$once('editorSetup', resolve); }); - const createComponent = async ({ state = {}, activeFile = dummyFile.text } = {}) => { + const createComponent = async ({ state = {}, activeFile = dummyFile.text, flags = {} } = {}) => { const store = prepareStore(state, activeFile); wrapper = shallowMount(RepoEditor, { store, @@ -118,6 +132,9 @@ describe('RepoEditor', () => { mocks: { ContentViewer, }, + provide: { + glFeatures: flags, + }, }); await waitForPromises(); vm = wrapper.vm; @@ -137,6 +154,7 @@ describe('RepoEditor', () => { createDiffInstanceSpy = jest.spyOn(SourceEditor.prototype, EDITOR_DIFF_INSTANCE_FN); createModelSpy = jest.spyOn(monacoEditor, 'createModel'); applyExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'use'); + removeExtensionSpy = jest.spyOn(SourceEditorInstance.prototype, 'unuse'); jest.spyOn(service, 'getFileData').mockResolvedValue(); jest.spyOn(service, 'getRawFileData').mockResolvedValue(); }); @@ -177,6 +195,76 @@ describe('RepoEditor', () => { }); }); + describe('schema registration for .gitlab-ci.yml', () => { + const setup = async (activeFile, flagIsOn = true) => { + await createComponent({ + flags: { + schemaLinting: flagIsOn, + }, + }); + vm.editor.registerCiSchema = jest.fn(); + if (activeFile) { + wrapper.setProps({ file: activeFile }); + } + await waitForPromises(); + await nextTick(); + }; + it.each` + flagIsOn | activeFile | shouldUseExtension | desc + ${false} | ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} + ${true} | ${dummyFile.markdown} | ${false} | ${`file is not CI config; should NOT`} + ${false} | ${dummyFile.ciConfig} | ${false} | ${`file is CI config; should NOT`} + ${true} | ${dummyFile.ciConfig} | ${true} | ${`file is CI config; should`} + `( + 'when the flag is "$flagIsOn", $desc use extension', + async ({ flagIsOn, activeFile, shouldUseExtension }) => { + await setup(activeFile, flagIsOn); + + if (shouldUseExtension) { + expect(applyExtensionSpy).toHaveBeenCalledWith({ + definition: CiSchemaExtension, + }); + } else { + expect(applyExtensionSpy).not.toHaveBeenCalledWith({ + definition: CiSchemaExtension, + }); + } + }, + ); + it('stores the fetched extension and does not double-fetch the schema', async () => { + await setup(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(0); + + wrapper.setProps({ file: dummyFile.ciConfig }); + await waitForPromises(); + await nextTick(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(1); + expect(vm.CiSchemaExtension).toEqual(CiSchemaExtension); + expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(1); + + wrapper.setProps({ file: dummyFile.markdown }); + await waitForPromises(); + await nextTick(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(1); + expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(1); + + wrapper.setProps({ file: dummyFile.ciConfig }); + await waitForPromises(); + await nextTick(); + expect(CiSchemaExtension).toHaveBeenCalledTimes(1); + expect(vm.editor.registerCiSchema).toHaveBeenCalledTimes(2); + }); + it('unuses the existing CI extension if the new model is not CI config', async () => { + await setup(dummyFile.ciConfig); + + expect(removeExtensionSpy).not.toHaveBeenCalled(); + wrapper.setProps({ file: dummyFile.markdown }); + await waitForPromises(); + await nextTick(); + expect(removeExtensionSpy).toHaveBeenCalledWith(CiSchemaExtension); + }); + }); + describe('when file is markdown', () => { let mock; let activeFile; diff --git a/spec/frontend/ide/components/switch_editors/switch_editors_view_spec.js b/spec/frontend/ide/components/switch_editors/switch_editors_view_spec.js deleted file mode 100644 index 7a958391fea..00000000000 --- a/spec/frontend/ide/components/switch_editors/switch_editors_view_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import { GlButton, GlEmptyState, GlLink } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import waitForPromises from 'helpers/wait_for_promises'; -import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import { createAlert } from '~/flash'; -import axios from '~/lib/utils/axios_utils'; -import { logError } from '~/lib/logger'; -import { __ } from '~/locale'; -import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'; -import SwitchEditorsView, { - MSG_ERROR_ALERT, - MSG_CONFIRM, - MSG_TITLE, - MSG_LEARN_MORE, - MSG_DESCRIPTION, -} from '~/ide/components/switch_editors/switch_editors_view.vue'; -import eventHub from '~/ide/eventhub'; -import { createStore } from '~/ide/stores'; - -jest.mock('~/flash'); -jest.mock('~/lib/logger'); -jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_via_gl_modal'); - -const TEST_USER_PREFERENCES_PATH = '/test/user-pref/path'; -const TEST_SWITCH_EDITOR_SVG_PATH = '/test/switch/editor/path.svg'; -const TEST_HREF = '/test/new/web/ide/href'; - -describe('~/ide/components/switch_editors/switch_editors_view.vue', () => { - useMockLocationHelper(); - - let store; - let wrapper; - let confirmResolve; - let requestSpy; - let skipBeforeunloadSpy; - let axiosMock; - - // region: finders ------------------ - const findButton = () => wrapper.findComponent(GlButton); - const findEmptyState = () => wrapper.findComponent(GlEmptyState); - - // region: actions ------------------ - const triggerSwitchPreference = () => findButton().vm.$emit('click'); - const submitConfirm = async (val) => { - confirmResolve(val); - - // why: We need to wait for promises for the immediate next lines to be executed - await waitForPromises(); - }; - - const createComponent = () => { - wrapper = shallowMount(SwitchEditorsView, { - store, - stubs: { - GlEmptyState, - }, - }); - }; - - // region: test setup ------------------ - beforeEach(() => { - // Setup skip-beforeunload side-effect - skipBeforeunloadSpy = jest.fn(); - eventHub.$on('skip-beforeunload', skipBeforeunloadSpy); - - // Setup request side-effect - requestSpy = jest.fn().mockImplementation(() => new Promise(() => {})); - axiosMock = new MockAdapter(axios); - axiosMock.onPut(TEST_USER_PREFERENCES_PATH).reply(({ data }) => requestSpy(data)); - - // Setup store - store = createStore(); - store.state.userPreferencesPath = TEST_USER_PREFERENCES_PATH; - store.state.switchEditorSvgPath = TEST_SWITCH_EDITOR_SVG_PATH; - store.state.links = { - newWebIDEHelpPagePath: TEST_HREF, - }; - - // Setup user confirm side-effect - confirmAction.mockImplementation( - () => - new Promise((resolve) => { - confirmResolve = resolve; - }), - ); - }); - - afterEach(() => { - eventHub.$off('skip-beforeunload', skipBeforeunloadSpy); - - axiosMock.restore(); - }); - - // region: tests ------------------ - describe('default', () => { - beforeEach(() => { - createComponent(); - }); - - it('render empty state', () => { - expect(findEmptyState().props()).toMatchObject({ - svgPath: TEST_SWITCH_EDITOR_SVG_PATH, - svgHeight: 150, - title: MSG_TITLE, - }); - }); - - it('render link', () => { - expect(wrapper.findComponent(GlLink).attributes('href')).toBe(TEST_HREF); - expect(wrapper.findComponent(GlLink).text()).toBe(MSG_LEARN_MORE); - }); - - it('renders description', () => { - expect(findEmptyState().text()).toContain(MSG_DESCRIPTION); - }); - - it('is not loading', () => { - expect(findButton().props('loading')).toBe(false); - }); - }); - - describe('when user triggers switch preference', () => { - beforeEach(() => { - createComponent(); - - triggerSwitchPreference(); - }); - - it('creates a single confirm', () => { - // Call again to ensure that we only show 1 confirm action - triggerSwitchPreference(); - - expect(confirmAction).toHaveBeenCalledTimes(1); - expect(confirmAction).toHaveBeenCalledWith(MSG_CONFIRM, { - primaryBtnText: __('Switch editors'), - cancelBtnText: __('Cancel'), - }); - }); - - it('starts loading', () => { - expect(findButton().props('loading')).toBe(true); - }); - - describe('when user cancels confirm', () => { - beforeEach(async () => { - await submitConfirm(false); - }); - - it('does not make request', () => { - expect(requestSpy).not.toHaveBeenCalled(); - }); - - it('can be triggered again', () => { - triggerSwitchPreference(); - - expect(confirmAction).toHaveBeenCalledTimes(2); - }); - }); - - describe('when user accepts confirm and response success', () => { - beforeEach(async () => { - requestSpy.mockReturnValue([200, {}]); - await submitConfirm(true); - }); - - it('does not handle error', () => { - expect(logError).not.toHaveBeenCalled(); - expect(createAlert).not.toHaveBeenCalled(); - }); - - it('emits "skip-beforeunload" and reloads', () => { - expect(skipBeforeunloadSpy).toHaveBeenCalledTimes(1); - expect(window.location.reload).toHaveBeenCalledTimes(1); - }); - - it('calls request', () => { - expect(requestSpy).toHaveBeenCalledTimes(1); - expect(requestSpy).toHaveBeenCalledWith( - JSON.stringify({ user: { use_legacy_web_ide: false } }), - ); - }); - - it('is not loading', () => { - expect(findButton().props('loading')).toBe(false); - }); - }); - - describe('when user accepts confirm and response fails', () => { - beforeEach(async () => { - requestSpy.mockReturnValue([400, {}]); - await submitConfirm(true); - }); - - it('handles error', () => { - expect(logError).toHaveBeenCalledTimes(1); - expect(logError).toHaveBeenCalledWith( - 'Error while updating user preferences', - expect.any(Error), - ); - - expect(createAlert).toHaveBeenCalledTimes(1); - expect(createAlert).toHaveBeenCalledWith({ - message: MSG_ERROR_ALERT, - }); - }); - - it('does not reload', () => { - expect(skipBeforeunloadSpy).not.toHaveBeenCalled(); - expect(window.location.reload).not.toHaveBeenCalled(); - }); - }); - }); -}); diff --git a/spec/frontend/ide/init_gitlab_web_ide_spec.js b/spec/frontend/ide/init_gitlab_web_ide_spec.js index 067da25cb52..97254ab680b 100644 --- a/spec/frontend/ide/init_gitlab_web_ide_spec.js +++ b/spec/frontend/ide/init_gitlab_web_ide_spec.js @@ -1,62 +1,190 @@ import { start } from '@gitlab/web-ide'; +import { GITLAB_WEB_IDE_FEEDBACK_ISSUE } from '~/ide/constants'; import { initGitlabWebIDE } from '~/ide/init_gitlab_web_ide'; +import { confirmAction } from '~/lib/utils/confirm_via_gl_modal/confirm_action'; +import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; import { TEST_HOST } from 'helpers/test_constants'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import waitForPromises from 'helpers/wait_for_promises'; jest.mock('@gitlab/web-ide'); +jest.mock('~/lib/utils/confirm_via_gl_modal/confirm_action'); +jest.mock('~/lib/utils/create_and_submit_form'); +jest.mock('~/lib/utils/csrf', () => ({ + token: 'mock-csrf-token', + headerKey: 'mock-csrf-header', +})); const ROOT_ELEMENT_ID = 'ide'; const TEST_NONCE = 'test123nonce'; const TEST_PROJECT_PATH = 'group1/project1'; const TEST_BRANCH_NAME = '12345-foo-patch'; const TEST_GITLAB_URL = 'https://test-gitlab/'; +const TEST_USER_PREFERENCES_PATH = '/user/preferences'; const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/webpack/assets/gitlab-web-ide/public/path'; +const TEST_FILE_PATH = 'foo/README.md'; +const TEST_MR_ID = '7'; +const TEST_MR_TARGET_PROJECT = 'gitlab-org/the-real-gitlab'; +const TEST_FORK_INFO = { fork_path: '/forky' }; +const TEST_IDE_REMOTE_PATH = '/-/ide/remote/:remote_host/:remote_path'; +const TEST_START_REMOTE_PARAMS = { + remoteHost: 'dev.example.gitlab.com/test', + remotePath: '/test/projects/f oo', + connectionToken: '123abc', +}; describe('ide/init_gitlab_web_ide', () => { + let resolveConfirm; + const createRootElement = () => { const el = document.createElement('div'); el.id = ROOT_ELEMENT_ID; // why: We'll test that this class is removed later - el.classList.add('ide-loading'); + el.classList.add('test-class'); el.dataset.projectPath = TEST_PROJECT_PATH; el.dataset.cspNonce = TEST_NONCE; el.dataset.branchName = TEST_BRANCH_NAME; + el.dataset.ideRemotePath = TEST_IDE_REMOTE_PATH; + el.dataset.userPreferencesPath = TEST_USER_PREFERENCES_PATH; + el.dataset.mergeRequest = TEST_MR_ID; + el.dataset.filePath = TEST_FILE_PATH; document.body.append(el); }; const findRootElement = () => document.getElementById(ROOT_ELEMENT_ID); - const act = () => initGitlabWebIDE(findRootElement()); + const createSubject = () => initGitlabWebIDE(findRootElement()); + const triggerHandleStartRemote = (startRemoteParams) => { + const [, config] = start.mock.calls[0]; + + config.handleStartRemote(startRemoteParams); + }; beforeEach(() => { process.env.GITLAB_WEB_IDE_PUBLIC_PATH = TEST_GITLAB_WEB_IDE_PUBLIC_PATH; window.gon.gitlab_url = TEST_GITLAB_URL; - createRootElement(); + confirmAction.mockImplementation( + () => + new Promise((resolve) => { + resolveConfirm = resolve; + }), + ); - act(); + createRootElement(); }); afterEach(() => { document.body.innerHTML = ''; }); - it('calls start with element', () => { - expect(start).toHaveBeenCalledWith(findRootElement(), { - baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, - projectPath: TEST_PROJECT_PATH, - ref: TEST_BRANCH_NAME, - gitlabUrl: TEST_GITLAB_URL, - nonce: TEST_NONCE, + describe('default', () => { + beforeEach(() => { + createSubject(); + }); + + it('calls start with element', () => { + expect(start).toHaveBeenCalledTimes(1); + expect(start).toHaveBeenCalledWith(findRootElement(), { + baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, + projectPath: TEST_PROJECT_PATH, + ref: TEST_BRANCH_NAME, + filePath: TEST_FILE_PATH, + mrId: TEST_MR_ID, + mrTargetProject: '', + forkInfo: null, + gitlabUrl: TEST_GITLAB_URL, + nonce: TEST_NONCE, + httpHeaders: { + 'mock-csrf-header': 'mock-csrf-token', + 'X-Requested-With': 'XMLHttpRequest', + }, + links: { + userPreferences: TEST_USER_PREFERENCES_PATH, + feedbackIssue: GITLAB_WEB_IDE_FEEDBACK_ISSUE, + }, + handleStartRemote: expect.any(Function), + }); + }); + + it('clears classes and data from root element', () => { + const rootEl = findRootElement(); + + // why: Snapshot to test that the element was cleaned including `test-class` + expect(rootEl.outerHTML).toBe( + '<div id="ide" class="gl--flex-center gl-relative gl-h-full"></div>', + ); + }); + + describe('when handleStartRemote is triggered', () => { + beforeEach(() => { + triggerHandleStartRemote(TEST_START_REMOTE_PARAMS); + }); + + it('promts for confirm', () => { + expect(confirmAction).toHaveBeenCalledWith(expect.any(String), { + primaryBtnText: expect.any(String), + cancelBtnText: expect.any(String), + }); + }); + + it('does not submit, when not confirmed', async () => { + resolveConfirm(false); + + await waitForPromises(); + + expect(createAndSubmitForm).not.toHaveBeenCalled(); + }); + + it('submits, when confirmed', async () => { + resolveConfirm(true); + + await waitForPromises(); + + expect(createAndSubmitForm).toHaveBeenCalledWith({ + url: '/-/ide/remote/dev.example.gitlab.com%2Ftest/test/projects/f%20oo', + data: { + connection_token: TEST_START_REMOTE_PARAMS.connectionToken, + return_url: window.location.href, + }, + }); + }); + }); + }); + + describe('when URL has target_project in query params', () => { + beforeEach(() => { + setWindowLocation( + `https://example.com/-/ide?target_project=${encodeURIComponent(TEST_MR_TARGET_PROJECT)}`, + ); + + createSubject(); + }); + + it('includes mrTargetProject', () => { + expect(start).toHaveBeenCalledWith( + findRootElement(), + expect.objectContaining({ + mrTargetProject: TEST_MR_TARGET_PROJECT, + }), + ); }); }); - it('clears classes and data from root element', () => { - const rootEl = findRootElement(); + describe('when forkInfo is in dataset', () => { + beforeEach(() => { + findRootElement().dataset.forkInfo = JSON.stringify(TEST_FORK_INFO); - // why: Snapshot to test that `ide-loading` was removed and no other - // artifacts are remaining. - expect(rootEl.outerHTML).toBe( - '<div id="ide" class="gl--flex-center gl-relative gl-h-full"></div>', - ); + createSubject(); + }); + + it('includes forkInfo', () => { + expect(start).toHaveBeenCalledWith( + findRootElement(), + expect.objectContaining({ + forkInfo: TEST_FORK_INFO, + }), + ); + }); }); }); diff --git a/spec/frontend/ide/lib/common/model_spec.js b/spec/frontend/ide/lib/common/model_spec.js index 5d1623429c0..39c50f628c2 100644 --- a/spec/frontend/ide/lib/common/model_spec.js +++ b/spec/frontend/ide/lib/common/model_spec.js @@ -149,7 +149,6 @@ describe('Multi-file editor library model', () => { model.updateOptions({ insertSpaces: true, someOption: 'some value' }); expect(model.options).toEqual({ - endOfLine: 0, insertFinalNewline: true, insertSpaces: true, someOption: 'some value', @@ -181,16 +180,12 @@ describe('Multi-file editor library model', () => { describe('applyCustomOptions', () => { it.each` option | value | contentBefore | contentAfter - ${'endOfLine'} | ${0} | ${'hello\nworld\n'} | ${'hello\nworld\n'} - ${'endOfLine'} | ${0} | ${'hello\r\nworld\r\n'} | ${'hello\nworld\n'} - ${'endOfLine'} | ${1} | ${'hello\nworld\n'} | ${'hello\r\nworld\r\n'} - ${'endOfLine'} | ${1} | ${'hello\r\nworld\r\n'} | ${'hello\r\nworld\r\n'} ${'insertFinalNewline'} | ${true} | ${'hello\nworld'} | ${'hello\nworld\n'} ${'insertFinalNewline'} | ${true} | ${'hello\nworld\n'} | ${'hello\nworld\n'} ${'insertFinalNewline'} | ${false} | ${'hello\nworld'} | ${'hello\nworld'} ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\nworld \t\n'} | ${'hello\nworld\n'} - ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\r\nworld \t\r\n'} | ${'hello\nworld\n'} - ${'trimTrailingWhitespace'} | ${false} | ${'hello \t\r\nworld \t\r\n'} | ${'hello \t\nworld \t\n'} + ${'trimTrailingWhitespace'} | ${true} | ${'hello \t\r\nworld \t\r\n'} | ${'hello\r\nworld\r\n'} + ${'trimTrailingWhitespace'} | ${false} | ${'hello \t\r\nworld \t\r\n'} | ${'hello \t\r\nworld \t\r\n'} `( 'correctly applies custom option $option=$value to content', ({ option, value, contentBefore, contentAfter }) => { diff --git a/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js new file mode 100644 index 00000000000..4b4e96f3b41 --- /dev/null +++ b/spec/frontend/ide/lib/gitlab_web_ide/get_base_config_spec.js @@ -0,0 +1,22 @@ +import { getBaseConfig } from '~/ide/lib/gitlab_web_ide/get_base_config'; +import { TEST_HOST } from 'helpers/test_constants'; + +const TEST_GITLAB_WEB_IDE_PUBLIC_PATH = 'test/gitlab-web-ide/public/path'; +const TEST_GITLAB_URL = 'https://gdk.test/'; + +describe('~/ide/lib/gitlab_web_ide/get_base_config', () => { + it('returns base properties for @gitlab/web-ide config', () => { + // why: add trailing "/" to test that it gets removed + process.env.GITLAB_WEB_IDE_PUBLIC_PATH = `${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}/`; + window.gon.gitlab_url = TEST_GITLAB_URL; + + // act + const actual = getBaseConfig(); + + // asset + expect(actual).toEqual({ + baseUrl: `${TEST_HOST}/${TEST_GITLAB_WEB_IDE_PUBLIC_PATH}`, + gitlabUrl: TEST_GITLAB_URL, + }); + }); +}); diff --git a/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js b/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js new file mode 100644 index 00000000000..35cf41b31f5 --- /dev/null +++ b/spec/frontend/ide/lib/gitlab_web_ide/setup_root_element_spec.js @@ -0,0 +1,32 @@ +import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; +import { setupRootElement } from '~/ide/lib/gitlab_web_ide/setup_root_element'; + +describe('~/ide/lib/gitlab_web_ide/setup_root_element', () => { + beforeEach(() => { + setHTMLFixture(` + <div id="ide-test-root" class="js-not-a-real-class"> + <span>We are loading lots of stuff...</span> + </div> + `); + }); + + afterEach(() => { + resetHTMLFixture(); + }); + + const findIDERoot = () => document.getElementById('ide-test-root'); + + it('has no children, has original ID, and classes', () => { + const result = setupRootElement(findIDERoot()); + + // why: Assert that the return element matches the new one found in the dom + // (implying a el.replaceWith...) + expect(result).toBe(findIDERoot()); + expect(result).toMatchInlineSnapshot(` + <div + class="gl--flex-center gl-relative gl-h-full" + id="ide-test-root" + /> + `); + }); +}); diff --git a/spec/frontend/ide/remote/index_spec.js b/spec/frontend/ide/remote/index_spec.js new file mode 100644 index 00000000000..0f23b0a4e45 --- /dev/null +++ b/spec/frontend/ide/remote/index_spec.js @@ -0,0 +1,91 @@ +import { startRemote } from '@gitlab/web-ide'; +import { getBaseConfig, setupRootElement } from '~/ide/lib/gitlab_web_ide'; +import { mountRemoteIDE } from '~/ide/remote'; +import { TEST_HOST } from 'helpers/test_constants'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; + +jest.mock('@gitlab/web-ide'); +jest.mock('~/ide/lib/gitlab_web_ide'); + +const TEST_DATA = { + remoteHost: 'example.com:3443', + remotePath: 'test/path/gitlab', + cspNonce: 'just7some8noncense', + connectionToken: 'connectAtoken', + returnUrl: 'https://example.com/return', +}; + +const TEST_BASE_CONFIG = { + gitlabUrl: '/test/gitlab', +}; + +const TEST_RETURN_URL_SAME_ORIGIN = `${TEST_HOST}/foo/example`; + +describe('~/ide/remote/index', () => { + useMockLocationHelper(); + const originalHref = window.location.href; + + let el; + let rootEl; + + beforeEach(() => { + el = document.createElement('div'); + Object.entries(TEST_DATA).forEach(([key, value]) => { + el.dataset[key] = value; + }); + + // Stub setupRootElement so we can assert on return element + rootEl = document.createElement('div'); + setupRootElement.mockReturnValue(rootEl); + + // Stub getBaseConfig so we can assert + getBaseConfig.mockReturnValue(TEST_BASE_CONFIG); + }); + + describe('default', () => { + beforeEach(() => { + mountRemoteIDE(el); + }); + + it('calls startRemote', () => { + expect(startRemote).toHaveBeenCalledWith(rootEl, { + ...TEST_BASE_CONFIG, + nonce: TEST_DATA.cspNonce, + connectionToken: TEST_DATA.connectionToken, + remoteAuthority: `/${TEST_DATA.remoteHost}`, + hostPath: `/${TEST_DATA.remotePath}`, + handleError: expect.any(Function), + handleClose: expect.any(Function), + }); + }); + }); + + describe.each` + returnUrl | fnName | reloadExpectation | hrefExpectation + ${TEST_DATA.returnUrl} | ${'handleError'} | ${1} | ${originalHref} + ${TEST_DATA.returnUrl} | ${'handleClose'} | ${1} | ${originalHref} + ${TEST_RETURN_URL_SAME_ORIGIN} | ${'handleClose'} | ${0} | ${TEST_RETURN_URL_SAME_ORIGIN} + ${TEST_RETURN_URL_SAME_ORIGIN} | ${'handleError'} | ${0} | ${TEST_RETURN_URL_SAME_ORIGIN} + ${''} | ${'handleClose'} | ${1} | ${originalHref} + `( + 'with returnUrl=$returnUrl and fn=$fnName', + ({ returnUrl, fnName, reloadExpectation, hrefExpectation }) => { + beforeEach(() => { + el.dataset.returnUrl = returnUrl; + + mountRemoteIDE(el); + }); + + it('changes location', () => { + expect(window.location.reload).not.toHaveBeenCalled(); + + const [, config] = startRemote.mock.calls[0]; + + config[fnName](); + + expect(window.location.reload).toHaveBeenCalledTimes(reloadExpectation); + expect(window.location.href).toBe(hrefExpectation); + }); + }, + ); +}); diff --git a/spec/frontend/ide/services/index_spec.js b/spec/frontend/ide/services/index_spec.js index 0fab828dfb3..5847e8e1518 100644 --- a/spec/frontend/ide/services/index_spec.js +++ b/spec/frontend/ide/services/index_spec.js @@ -6,7 +6,7 @@ import dismissUserCallout from '~/graphql_shared/mutations/dismiss_user_callout. import services from '~/ide/services'; import { query, mutate } from '~/ide/services/gql'; import { escapeFileUrl } from '~/lib/utils/url_utility'; -import ciConfig from '~/pipeline_editor/graphql/queries/ci_config.query.graphql'; +import ciConfig from '~/ci/pipeline_editor/graphql/queries/ci_config.query.graphql'; import { projectData } from '../mock_data'; jest.mock('~/api'); diff --git a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js index fc00bd075e7..8d21088bcaf 100644 --- a/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/actions/checks_spec.js @@ -10,7 +10,7 @@ import { import * as messages from '~/ide/stores/modules/terminal/messages'; import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; const TEST_PROJECT_PATH = 'lorem/root'; const TEST_BRANCH_ID = 'main'; @@ -78,7 +78,7 @@ describe('IDE store terminal check actions', () => { describe('receiveConfigCheckError', () => { it('handles error response', () => { - const status = httpStatus.UNPROCESSABLE_ENTITY; + const status = HTTP_STATUS_UNPROCESSABLE_ENTITY; const payload = { response: { status } }; return testAction( 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 f48797415df..df365442c67 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 { STARTING, PENDING, STOPPING, STOPPED } from '~/ide/stores/modules/termi import * as messages from '~/ide/stores/modules/terminal/messages'; import * as mutationTypes from '~/ide/stores/modules/terminal/mutation_types'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; jest.mock('~/flash'); @@ -285,7 +285,7 @@ describe('IDE store terminal session controls actions', () => { ); }); - [httpStatus.NOT_FOUND, httpStatus.UNPROCESSABLE_ENTITY].forEach((status) => { + [httpStatus.NOT_FOUND, HTTP_STATUS_UNPROCESSABLE_ENTITY].forEach((status) => { it(`dispatches request and startSession on ${status}`, () => { mock .onPost(state.session.retryPath, { branch: rootState.currentBranchId, format: 'json' }) diff --git a/spec/frontend/ide/stores/modules/terminal/messages_spec.js b/spec/frontend/ide/stores/modules/terminal/messages_spec.js index e8f375a70b5..2a802d6b4af 100644 --- a/spec/frontend/ide/stores/modules/terminal/messages_spec.js +++ b/spec/frontend/ide/stores/modules/terminal/messages_spec.js @@ -1,7 +1,7 @@ import { escape } from 'lodash'; import { TEST_HOST } from 'spec/test_constants'; import * as messages from '~/ide/stores/modules/terminal/messages'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_UNPROCESSABLE_ENTITY } from '~/lib/utils/http_status'; import { sprintf } from '~/locale'; const TEST_HELP_URL = `${TEST_HOST}/help`; @@ -9,7 +9,7 @@ const TEST_HELP_URL = `${TEST_HOST}/help`; describe('IDE store terminal messages', () => { describe('configCheckError', () => { it('returns job error, with status UNPROCESSABLE_ENTITY', () => { - const result = messages.configCheckError(httpStatus.UNPROCESSABLE_ENTITY, TEST_HELP_URL); + const result = messages.configCheckError(HTTP_STATUS_UNPROCESSABLE_ENTITY, TEST_HELP_URL); expect(result).toBe( sprintf( diff --git a/spec/frontend/import_entities/components/group_dropdown_spec.js b/spec/frontend/import_entities/components/group_dropdown_spec.js index b896437ecb2..31e097cfa7b 100644 --- a/spec/frontend/import_entities/components/group_dropdown_spec.js +++ b/spec/frontend/import_entities/components/group_dropdown_spec.js @@ -1,16 +1,61 @@ import { GlSearchBoxByType, GlDropdown } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import GroupDropdown from '~/import_entities/components/group_dropdown.vue'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; + +Vue.use(VueApollo); + +const makeGroupMock = (fullPath) => ({ + id: `gid://gitlab/Group/${fullPath}`, + fullPath, + name: fullPath, + visibility: 'public', + webUrl: `http://gdk.test:3000/groups/${fullPath}`, + __typename: 'Group', +}); + +const AVAILABLE_NAMESPACES = [ + makeGroupMock('match1'), + makeGroupMock('unrelated'), + makeGroupMock('match2'), +]; + +const SEARCH_NAMESPACES_MOCK = Promise.resolve({ + data: { + currentUser: { + id: 'gid://gitlab/User/1', + groups: { + nodes: AVAILABLE_NAMESPACES, + __typename: 'GroupConnection', + }, + namespace: { + id: 'gid://gitlab/Namespaces::UserNamespace/1', + fullPath: 'root', + __typename: 'Namespace', + }, + __typename: 'UserCore', + }, + }, +}); describe('Import entities group dropdown component', () => { let wrapper; let namespacesTracker; const createComponent = (propsData) => { + const apolloProvider = createMockApollo([ + [searchNamespacesWhereUserCanCreateProjectsQuery, () => SEARCH_NAMESPACES_MOCK], + ]); + namespacesTracker = jest.fn(); wrapper = shallowMount(GroupDropdown, { + apolloProvider, scopedSlots: { default: namespacesTracker, }, @@ -23,33 +68,30 @@ describe('Import entities group dropdown component', () => { wrapper.destroy(); }); - it('passes namespaces from props to default slot', () => { - const namespaces = [ - { id: 1, fullPath: 'ns1' }, - { id: 2, fullPath: 'ns2' }, - ]; - createComponent({ namespaces }); + it('passes namespaces from graphql query to default slot', async () => { + createComponent(); + jest.advanceTimersByTime(DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); + await nextTick(); - expect(namespacesTracker).toHaveBeenCalledWith({ namespaces }); + expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: AVAILABLE_NAMESPACES }); }); it('filters namespaces based on user input', async () => { - const namespaces = [ - { id: 1, fullPath: 'match1' }, - { id: 2, fullPath: 'some unrelated' }, - { id: 3, fullPath: 'match2' }, - ]; - createComponent({ namespaces }); + createComponent(); namespacesTracker.mockReset(); wrapper.findComponent(GlSearchBoxByType).vm.$emit('input', 'match'); - + jest.advanceTimersByTime(DEBOUNCE_DELAY); + await nextTick(); + await waitForPromises(); await nextTick(); expect(namespacesTracker).toHaveBeenCalledWith({ namespaces: [ - { id: 1, fullPath: 'match1' }, - { id: 3, fullPath: 'match2' }, + expect.objectContaining({ fullPath: 'match1' }), + expect.objectContaining({ fullPath: 'match2' }), ], }); }); diff --git a/spec/frontend/import_entities/import_groups/components/import_table_spec.js b/spec/frontend/import_entities/import_groups/components/import_table_spec.js index 61f860688dc..f7a97f22d44 100644 --- a/spec/frontend/import_entities/import_groups/components/import_table_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_table_spec.js @@ -15,8 +15,13 @@ import ImportTable from '~/import_entities/import_groups/components/import_table import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import PaginationBar from '~/vue_shared/components/pagination_bar/pagination_bar.vue'; import PaginationLinks from '~/vue_shared/components/pagination_links.vue'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; -import { availableNamespacesFixture, generateFakeEntry } from '../graphql/fixtures'; +import { + AVAILABLE_NAMESPACES, + availableNamespacesFixture, + generateFakeEntry, +} from '../graphql/fixtures'; jest.mock('~/flash'); jest.mock('~/import_entities/import_groups/services/status_poller'); @@ -60,15 +65,22 @@ describe('import table', () => { wrapper.findAll('tbody td input[type=checkbox]').at(idx).setChecked(true); const createComponent = ({ bulkImportSourceGroups, importGroups, defaultTargetNamespace }) => { - apolloProvider = createMockApollo([], { - Query: { - availableNamespaces: () => availableNamespacesFixture, - bulkImportSourceGroups, - }, - Mutation: { - importGroups, + apolloProvider = createMockApollo( + [ + [ + searchNamespacesWhereUserCanCreateProjectsQuery, + () => Promise.resolve(availableNamespacesFixture), + ], + ], + { + Query: { + bulkImportSourceGroups, + }, + Mutation: { + importGroups, + }, }, - }); + ); wrapper = mount(ImportTable, { propsData: { @@ -173,7 +185,7 @@ describe('import table', () => { }); it('respects default namespace if provided', async () => { - const targetNamespace = availableNamespacesFixture[1]; + const targetNamespace = AVAILABLE_NAMESPACES[1]; createComponent({ bulkImportSourceGroups: () => ({ @@ -227,7 +239,7 @@ describe('import table', () => { { newName: FAKE_GROUP.lastImportTarget.newName, sourceGroupId: FAKE_GROUP.id, - targetNamespace: availableNamespacesFixture[0].fullPath, + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, }, ], }, @@ -519,12 +531,12 @@ describe('import table', () => { variables: { importRequests: [ { - targetNamespace: availableNamespacesFixture[0].fullPath, + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, newName: NEW_GROUPS[0].lastImportTarget.newName, sourceGroupId: NEW_GROUPS[0].id, }, { - targetNamespace: availableNamespacesFixture[0].fullPath, + targetNamespace: AVAILABLE_NAMESPACES[0].fullPath, newName: NEW_GROUPS[1].lastImportTarget.newName, sourceGroupId: NEW_GROUPS[1].id, }, diff --git a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js index 18dc1217fec..d5286e71c44 100644 --- a/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js +++ b/spec/frontend/import_entities/import_groups/components/import_target_cell_spec.js @@ -1,9 +1,22 @@ import { GlDropdownItem, GlFormInput } from '@gitlab/ui'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; import { shallowMount } from '@vue/test-utils'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; import ImportGroupDropdown from '~/import_entities/components/group_dropdown.vue'; import { STATUSES } from '~/import_entities/constants'; import ImportTargetCell from '~/import_entities/import_groups/components/import_target_cell.vue'; -import { generateFakeEntry, availableNamespacesFixture } from '../graphql/fixtures'; +import { DEBOUNCE_DELAY } from '~/vue_shared/components/filtered_search_bar/constants'; +import searchNamespacesWhereUserCanCreateProjectsQuery from '~/projects/new/queries/search_namespaces_where_user_can_create_projects.query.graphql'; + +import { + generateFakeEntry, + availableNamespacesFixture, + AVAILABLE_NAMESPACES, +} from '../graphql/fixtures'; + +Vue.use(VueApollo); const generateFakeTableEntry = ({ flags = {}, ...config }) => { const entry = generateFakeEntry(config); @@ -11,7 +24,7 @@ const generateFakeTableEntry = ({ flags = {}, ...config }) => { return { ...entry, importTarget: { - targetNamespace: availableNamespacesFixture[0], + targetNamespace: AVAILABLE_NAMESPACES[0], newName: entry.lastImportTarget.newName, }, flags, @@ -20,16 +33,24 @@ const generateFakeTableEntry = ({ flags = {}, ...config }) => { describe('import target cell', () => { let wrapper; + let apolloProvider; let group; const findNameInput = () => wrapper.findComponent(GlFormInput); const findNamespaceDropdown = () => wrapper.findComponent(ImportGroupDropdown); const createComponent = (props) => { + apolloProvider = createMockApollo([ + [ + searchNamespacesWhereUserCanCreateProjectsQuery, + () => Promise.resolve(availableNamespacesFixture), + ], + ]); + wrapper = shallowMount(ImportTargetCell, { + apolloProvider, stubs: { ImportGroupDropdown }, propsData: { - availableNamespaces: availableNamespacesFixture, groupPathRegex: /.*/, ...props, }, @@ -42,9 +63,12 @@ describe('import target cell', () => { }); describe('events', () => { - beforeEach(() => { + beforeEach(async () => { group = generateFakeTableEntry({ id: 1, status: STATUSES.NONE }); createComponent({ group }); + await nextTick(); + jest.advanceTimersByTime(DEBOUNCE_DELAY); + await nextTick(); }); it('emits update-new-name when input value is changed', () => { @@ -59,7 +83,9 @@ describe('import target cell', () => { dropdownItem.vm.$emit('click'); expect(wrapper.emitted('update-target-namespace')).toBeDefined(); - expect(wrapper.emitted('update-target-namespace')[0][0]).toBe(availableNamespacesFixture[1]); + expect(wrapper.emitted('update-target-namespace')[0][0]).toStrictEqual( + AVAILABLE_NAMESPACES[1], + ); }); }); @@ -94,18 +120,20 @@ describe('import target cell', () => { expect(items).toHaveLength(1); }); - it('renders both no parent option and available namespaces list when available namespaces list is not empty', () => { + it('renders both no parent option and available namespaces list when available namespaces list is not empty', async () => { createComponent({ group: generateFakeTableEntry({ id: 1, status: STATUSES.NONE }), - availableNamespaces: availableNamespacesFixture, }); + jest.advanceTimersByTime(DEBOUNCE_DELAY); + await waitForPromises(); + await nextTick(); const [firstItem, ...rest] = findNamespaceDropdown() .findAllComponents(GlDropdownItem) .wrappers.map((w) => w.text()); expect(firstItem).toBe('No parent'); - expect(rest).toHaveLength(availableNamespacesFixture.length); + expect(rest).toHaveLength(AVAILABLE_NAMESPACES.length); }); describe('when entity is not available for import', () => { diff --git a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js index 52c868e5356..adc4ebcffb8 100644 --- a/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js +++ b/spec/frontend/import_entities/import_groups/graphql/client_factory_spec.js @@ -10,12 +10,11 @@ import { import { LocalStorageCache } from '~/import_entities/import_groups/graphql/services/local_storage_cache'; import importGroupsMutation from '~/import_entities/import_groups/graphql/mutations/import_groups.mutation.graphql'; import updateImportStatusMutation from '~/import_entities/import_groups/graphql/mutations/update_import_status.mutation.graphql'; -import availableNamespacesQuery from '~/import_entities/import_groups/graphql/queries/available_namespaces.query.graphql'; import bulkImportSourceGroupsQuery from '~/import_entities/import_groups/graphql/queries/bulk_import_source_groups.query.graphql'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; -import { statusEndpointFixture, availableNamespacesFixture } from './fixtures'; +import { statusEndpointFixture } from './fixtures'; jest.mock('~/flash'); jest.mock('~/import_entities/import_groups/graphql/services/local_storage_cache', () => ({ @@ -28,7 +27,6 @@ jest.mock('~/import_entities/import_groups/graphql/services/local_storage_cache' const FAKE_ENDPOINTS = { status: '/fake_status_url', - availableNamespaces: '/fake_available_namespaces', createBulkImport: '/fake_create_bulk_import', jobs: '/fake_jobs', }; @@ -55,14 +53,6 @@ describe('Bulk import resolvers', () => { client = createClient(); axiosMockAdapter.onGet(FAKE_ENDPOINTS.status).reply(httpStatus.OK, statusEndpointFixture); - axiosMockAdapter.onGet(FAKE_ENDPOINTS.availableNamespaces).reply( - httpStatus.OK, - availableNamespacesFixture.map((ns) => ({ - id: ns.id, - full_path: ns.fullPath, - })), - ); - client.watchQuery({ query: bulkImportSourceGroupsQuery }).subscribe(({ data }) => { results = data.bulkImportSourceGroups.nodes; }); @@ -75,22 +65,6 @@ describe('Bulk import resolvers', () => { }); describe('queries', () => { - describe('availableNamespaces', () => { - let namespacesResults; - beforeEach(async () => { - const response = await client.query({ query: availableNamespacesQuery }); - namespacesResults = response.data.availableNamespaces; - }); - - it('mirrors REST endpoint response fields', () => { - const extractRelevantFields = (obj) => ({ id: obj.id, full_path: obj.full_path }); - - expect(namespacesResults.map(extractRelevantFields)).toStrictEqual( - availableNamespacesFixture.map(extractRelevantFields), - ); - }); - }); - describe('bulkImportSourceGroups', () => { it('respects cached import state when provided by group manager', async () => { const [localStorageCache] = LocalStorageCache.mock.instances; diff --git a/spec/frontend/import_entities/import_groups/graphql/fixtures.js b/spec/frontend/import_entities/import_groups/graphql/fixtures.js index 938020e03f0..7530e9fc348 100644 --- a/spec/frontend/import_entities/import_groups/graphql/fixtures.js +++ b/spec/frontend/import_entities/import_groups/graphql/fixtures.js @@ -59,9 +59,36 @@ export const statusEndpointFixture = { }, }; -export const availableNamespacesFixture = Object.freeze([ - { id: 24, fullPath: 'Commit451' }, - { id: 22, fullPath: 'gitlab-org' }, - { id: 23, fullPath: 'gnuwget' }, - { id: 25, fullPath: 'jashkenas' }, -]); +const makeGroupMock = ({ id, fullPath }) => ({ + id, + fullPath, + name: fullPath, + visibility: 'public', + webUrl: `http://gdk.test:3000/groups/${fullPath}`, + __typename: 'Group', +}); + +export const AVAILABLE_NAMESPACES = [ + makeGroupMock({ id: 24, fullPath: 'Commit451' }), + makeGroupMock({ id: 22, fullPath: 'gitlab-org' }), + makeGroupMock({ id: 23, fullPath: 'gnuwget' }), + makeGroupMock({ id: 25, fullPath: 'jashkenas' }), +]; + +export const availableNamespacesFixture = { + data: { + currentUser: { + id: 'gid://gitlab/User/1', + groups: { + nodes: AVAILABLE_NAMESPACES, + __typename: 'GroupConnection', + }, + namespace: { + id: 'gid://gitlab/Namespaces::UserNamespace/1', + fullPath: 'root', + __typename: 'Namespace', + }, + __typename: 'UserCore', + }, + }, +}; diff --git a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js index 53807167fe8..51f82dab381 100644 --- a/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js +++ b/spec/frontend/import_entities/import_projects/components/import_projects_table_spec.js @@ -59,7 +59,6 @@ describe('ImportProjectsTable', () => { actions: { fetchRepos: fetchReposFn, fetchJobs: jest.fn(), - fetchNamespaces: jest.fn(), importAll: importAllFn, stopJobsPolling: jest.fn(), clearJobsEtagPoll: jest.fn(), @@ -95,12 +94,6 @@ describe('ImportProjectsTable', () => { expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); }); - it('renders a loading icon while namespaces are loading', () => { - createComponent({ state: { isLoadingNamespaces: true } }); - - expect(wrapper.findComponent(GlLoadingIcon).exists()).toBe(true); - }); - it('renders a table with provider repos', () => { const repositories = [ { importSource: { id: 1 }, importedProject: null }, @@ -214,35 +207,52 @@ describe('ImportProjectsTable', () => { }); describe('when paginatable is set to true', () => { - const pageInfo = { page: 1 }; + const initState = { + namespaces: [{ fullPath: 'path' }], + pageInfo: { page: 1, hasNextPage: true }, + repositories: [ + { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, + ], + }; + + describe('with hasNextPage true', () => { + beforeEach(() => { + createComponent({ + state: initState, + paginatable: true, + }); + }); - beforeEach(() => { - createComponent({ - state: { - namespaces: [{ fullPath: 'path' }], - pageInfo, - repositories: [ - { importSource: { id: 1 }, importedProject: null, importStatus: STATUSES.NONE }, - ], - }, - paginatable: true, + it('does not call fetchRepos on mount', () => { + expect(fetchReposFn).not.toHaveBeenCalled(); }); - }); - it('does not call fetchRepos on mount', () => { - expect(fetchReposFn).not.toHaveBeenCalled(); - }); + it('renders intersection observer component', () => { + expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true); + }); + + it('calls fetchRepos when intersection observer appears', async () => { + wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); - it('renders intersection observer component', () => { - expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(true); + await nextTick(); + + expect(fetchReposFn).toHaveBeenCalled(); + }); }); - it('calls fetchRepos when intersection observer appears', async () => { - wrapper.findComponent(GlIntersectionObserver).vm.$emit('appear'); + describe('with hasNextPage false', () => { + beforeEach(() => { + initState.pageInfo.hasNextPage = false; - await nextTick(); + createComponent({ + state: initState, + paginatable: true, + }); + }); - expect(fetchReposFn).toHaveBeenCalled(); + it('does not render intersection observer component', () => { + expect(wrapper.findComponent(GlIntersectionObserver).exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js index 40934e90b78..d686036781f 100644 --- a/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js +++ b/spec/frontend/import_entities/import_projects/components/provider_repo_table_row_spec.js @@ -10,13 +10,13 @@ import ProviderRepoTableRow from '~/import_entities/import_projects/components/p describe('ProviderRepoTableRow', () => { let wrapper; const fetchImport = jest.fn(); + const cancelImport = jest.fn(); const setImportTarget = jest.fn(); const fakeImportTarget = { targetNamespace: 'target', newName: 'newName', }; - const availableNamespaces = ['test']; const userNamespace = 'root'; function initStore(initialState) { @@ -25,7 +25,7 @@ describe('ProviderRepoTableRow', () => { getters: { getImportTarget: () => () => fakeImportTarget, }, - actions: { fetchImport, setImportTarget }, + actions: { fetchImport, cancelImport, setImportTarget }, }); return store; @@ -37,6 +37,14 @@ describe('ProviderRepoTableRow', () => { return buttons.length ? buttons.at(0) : buttons; }; + const findCancelButton = () => { + const buttons = wrapper + .findAllComponents(GlButton) + .filter((node) => node.attributes('aria-label') === 'Cancel'); + + return buttons.length ? buttons.at(0) : buttons; + }; + function mountComponent(props) { Vue.use(Vuex); @@ -44,7 +52,7 @@ describe('ProviderRepoTableRow', () => { wrapper = shallowMount(ProviderRepoTableRow, { store, - propsData: { availableNamespaces, userNamespace, optionalStages: {}, ...props }, + propsData: { userNamespace, optionalStages: {}, ...props }, }); } @@ -78,9 +86,7 @@ describe('ProviderRepoTableRow', () => { }); it('renders a group namespace select', () => { - expect(wrapper.findComponent(ImportGroupDropdown).props().namespaces).toBe( - availableNamespaces, - ); + expect(wrapper.findComponent(ImportGroupDropdown).exists()).toBe(true); }); it('renders import button', () => { @@ -113,6 +119,52 @@ describe('ProviderRepoTableRow', () => { }); }); + describe('when rendering importing project', () => { + const repo = { + importSource: { + id: 'remote-1', + fullName: 'fullName', + providerLink: 'providerLink', + }, + importedProject: { + id: 1, + fullPath: 'fullPath', + importSource: 'importSource', + importStatus: STATUSES.STARTED, + }, + }; + + describe('when cancelable is true', () => { + beforeEach(() => { + mountComponent({ repo, cancelable: true }); + }); + + it('shows cancel button', () => { + expect(findCancelButton().isVisible()).toBe(true); + }); + + it('cancels import when clicking cancel button', async () => { + findCancelButton().vm.$emit('click'); + + await nextTick(); + + expect(cancelImport).toHaveBeenCalledWith(expect.anything(), { + repoId: repo.importSource.id, + }); + }); + }); + + describe('when cancelable is false', () => { + beforeEach(() => { + mountComponent({ repo, cancelable: false }); + }); + + it('hides cancel button', () => { + expect(findCancelButton().isVisible()).toBe(false); + }); + }); + }); + describe('when rendering imported project', () => { const FAKE_STATS = {}; diff --git a/spec/frontend/import_entities/import_projects/store/actions_spec.js b/spec/frontend/import_entities/import_projects/store/actions_spec.js index e154863f339..4b34c21daa3 100644 --- a/spec/frontend/import_entities/import_projects/store/actions_spec.js +++ b/spec/frontend/import_entities/import_projects/store/actions_spec.js @@ -2,7 +2,7 @@ import MockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import testAction from 'helpers/vuex_action_helper'; import { createAlert } from '~/flash'; -import { STATUSES } from '~/import_entities/constants'; +import { STATUSES, PROVIDERS } from '~/import_entities/constants'; import actionsFactory from '~/import_entities/import_projects/store/actions'; import { getImportTarget } from '~/import_entities/import_projects/store/getters'; import { @@ -13,11 +13,10 @@ import { RECEIVE_IMPORT_SUCCESS, RECEIVE_IMPORT_ERROR, RECEIVE_JOBS_SUCCESS, - REQUEST_NAMESPACES, - RECEIVE_NAMESPACES_SUCCESS, - RECEIVE_NAMESPACES_ERROR, + CANCEL_IMPORT_SUCCESS, SET_PAGE, SET_FILTER, + SET_PAGE_CURSORS, } from '~/import_entities/import_projects/store/mutation_types'; import state from '~/import_entities/import_projects/store/state'; import axios from '~/lib/utils/axios_utils'; @@ -30,7 +29,7 @@ const endpoints = { reposPath: MOCK_ENDPOINT, importPath: MOCK_ENDPOINT, jobsPath: MOCK_ENDPOINT, - namespacesPath: MOCK_ENDPOINT, + cancelPath: MOCK_ENDPOINT, }; const { @@ -39,8 +38,8 @@ const { importAll, fetchRepos, fetchImport, + cancelImport, fetchJobs, - fetchNamespaces, setFilter, } = actionsFactory({ endpoints, @@ -59,14 +58,17 @@ describe('import_projects store actions', () => { ...state(), defaultTargetNamespace, repositories: [ - { importSource: { id: importRepoId, sanitizedName }, importStatus: STATUSES.NONE }, + { + importSource: { id: importRepoId, sanitizedName }, + importedProject: { importStatus: STATUSES.NONE }, + }, { importSource: { id: otherImportRepoId, sanitizedName: 's2' }, - importStatus: STATUSES.NONE, + importedProject: { importStatus: STATUSES.NONE }, }, { importSource: { id: 3, sanitizedName: 's3', incompatible: true }, - importStatus: STATUSES.NONE, + importedProject: { importStatus: STATUSES.NONE }, }, ], provider: 'provider', @@ -77,7 +79,11 @@ describe('import_projects store actions', () => { describe('fetchRepos', () => { let mock; - const payload = { imported_projects: [{}], provider_repos: [{}] }; + const payload = { + imported_projects: [{}], + provider_repos: [{}], + page_info: { startCursor: 'start', endCursor: 'end', hasNextPage: true }, + }; beforeEach(() => { mock = new MockAdapter(axios); @@ -85,23 +91,53 @@ describe('import_projects store actions', () => { afterEach(() => mock.restore()); - it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations on a successful request', () => { - mock.onGet(MOCK_ENDPOINT).reply(200, payload); + describe('with a successful request', () => { + it('commits REQUEST_REPOS, SET_PAGE, RECEIVE_REPOS_SUCCESS mutations', () => { + mock.onGet(MOCK_ENDPOINT).reply(200, payload); - return testAction( - fetchRepos, - null, - localState, - [ - { type: REQUEST_REPOS }, - { type: SET_PAGE, payload: 1 }, - { - type: RECEIVE_REPOS_SUCCESS, - payload: convertObjectPropsToCamelCase(payload, { deep: true }), - }, - ], - [], - ); + return testAction( + fetchRepos, + null, + localState, + [ + { type: REQUEST_REPOS }, + { type: SET_PAGE, payload: 1 }, + { + type: RECEIVE_REPOS_SUCCESS, + payload: convertObjectPropsToCamelCase(payload, { deep: true }), + }, + ], + [], + ); + }); + + describe('when provider is GITHUB_PROVIDER', () => { + beforeEach(() => { + localState.provider = PROVIDERS.GITHUB; + }); + + it('commits SET_PAGE_CURSORS instead of SET_PAGE', () => { + mock.onGet(MOCK_ENDPOINT).reply(200, payload); + + return testAction( + fetchRepos, + null, + localState, + [ + { type: REQUEST_REPOS }, + { + type: SET_PAGE_CURSORS, + payload: { startCursor: 'start', endCursor: 'end', hasNextPage: true }, + }, + { + type: RECEIVE_REPOS_SUCCESS, + payload: convertObjectPropsToCamelCase(payload, { deep: true }), + }, + ], + [], + ); + }); + }); }); it('commits REQUEST_REPOS, RECEIVE_REPOS_ERROR mutations on an unsuccessful request', () => { @@ -116,18 +152,52 @@ describe('import_projects store actions', () => { ); }); - it('includes page in url query params', async () => { - let requestedUrl; - mock.onGet().reply((config) => { - requestedUrl = config.url; - return [200, payload]; + describe('with pagination params', () => { + it('includes page in url query params', async () => { + let requestedUrl; + mock.onGet().reply((config) => { + requestedUrl = config.url; + return [200, payload]; + }); + + const localStateWithPage = { ...localState, pageInfo: { page: 2 } }; + + await testAction( + fetchRepos, + null, + localStateWithPage, + expect.any(Array), + expect.any(Array), + ); + + expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`); }); - const localStateWithPage = { ...localState, pageInfo: { page: 2 } }; + describe('when provider is "github"', () => { + beforeEach(() => { + localState.provider = PROVIDERS.GITHUB; + }); + + it('includes cursor in url query params', async () => { + let requestedUrl; + mock.onGet().reply((config) => { + requestedUrl = config.url; + return [200, payload]; + }); - await testAction(fetchRepos, null, localStateWithPage, expect.any(Array), expect.any(Array)); + const localStateWithPage = { ...localState, pageInfo: { endCursor: 'endTest' } }; - expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?page=${localStateWithPage.pageInfo.page + 1}`); + await testAction( + fetchRepos, + null, + localStateWithPage, + expect.any(Array), + expect.any(Array), + ); + + expect(requestedUrl).toBe(`${MOCK_ENDPOINT}?after=endTest`); + }); + }); }); it('correctly keeps current page on an unsuccessful request', () => { @@ -319,51 +389,6 @@ 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(createAlert).toHaveBeenCalledWith({ - message: 'Requesting namespaces failed', - }); - }); - }); - describe('importAll', () => { it('dispatches multiple fetchImport actions', async () => { const OPTIONAL_STAGES = { stage1: true, stage2: false }; @@ -398,4 +423,51 @@ describe('import_projects store actions', () => { ); }); }); + + describe('cancelImport', () => { + let mock; + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => mock.restore()); + + it('commits CANCEL_IMPORT_SUCCESS on success', async () => { + mock.onPost(MOCK_ENDPOINT).reply(200); + + await testAction( + cancelImport, + { repoId: importRepoId }, + localState, + [ + { + type: CANCEL_IMPORT_SUCCESS, + payload: { repoId: 1 }, + }, + ], + [], + ); + }); + + it('shows generic error message on an unsuccessful request', async () => { + mock.onPost(MOCK_ENDPOINT).reply(500); + + await testAction(cancelImport, { repoId: importRepoId }, localState, [], []); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'Cancelling project import failed', + }); + }); + + it('shows detailed error message on an unsuccessful request with errors fields in response', async () => { + const ERROR_MESSAGE = 'dummy'; + mock.onPost(MOCK_ENDPOINT).reply(500, { errors: ERROR_MESSAGE }); + + await testAction(cancelImport, { repoId: importRepoId }, localState, [], []); + + expect(createAlert).toHaveBeenCalledWith({ + message: `Cancelling project import failed: ${ERROR_MESSAGE}`, + }); + }); + }); }); diff --git a/spec/frontend/import_entities/import_projects/store/getters_spec.js b/spec/frontend/import_entities/import_projects/store/getters_spec.js index 110b692b222..fced5670f25 100644 --- a/spec/frontend/import_entities/import_projects/store/getters_spec.js +++ b/spec/frontend/import_entities/import_projects/store/getters_spec.js @@ -1,6 +1,5 @@ import { STATUSES } from '~/import_entities/constants'; import { - isLoading, isImportingAnyRepo, hasIncompatibleRepos, hasImportableRepos, @@ -31,24 +30,6 @@ describe('import_projects store getters', () => { }); 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(isLoading(localState)).toBe(isLoadingValue); - }, - ); - - it.each` importStatus | value ${STATUSES.NONE} | ${false} ${STATUSES.SCHEDULING} | ${true} diff --git a/spec/frontend/import_entities/import_projects/store/mutations_spec.js b/spec/frontend/import_entities/import_projects/store/mutations_spec.js index 77fae951300..7884e9b4307 100644 --- a/spec/frontend/import_entities/import_projects/store/mutations_spec.js +++ b/spec/frontend/import_entities/import_projects/store/mutations_spec.js @@ -27,7 +27,12 @@ describe('import_projects store mutations', () => { state = { filter: 'some-value', repositories: ['some', ' repositories'], - pageInfo: { page: 1 }, + pageInfo: { + page: 1, + startCursor: 'Y3Vyc30yOjI2', + endCursor: 'Y3Vyc29yOjI1', + hasNextPage: false, + }, }; mutations[types.SET_FILTER](state, NEW_VALUE); }); @@ -36,8 +41,11 @@ describe('import_projects store mutations', () => { expect(state.repositories.length).toBe(0); }); - it('resets current page to 0', () => { + it('resets pagintation', () => { expect(state.pageInfo.page).toBe(0); + expect(state.pageInfo.startCursor).toBe(null); + expect(state.pageInfo.endCursor).toBe(null); + expect(state.pageInfo.hasNextPage).toBe(true); }); }); @@ -263,43 +271,6 @@ describe('import_projects store mutations', () => { }); }); - 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, @@ -345,4 +316,34 @@ describe('import_projects store mutations', () => { expect(state.pageInfo.page).toBe(NEW_PAGE); }); }); + + describe(`${types.SET_PAGE_CURSORS}`, () => { + it('sets page cursors', () => { + const NEW_CURSORS = { startCursor: 'startCur', endCursor: 'endCur', hasNextPage: false }; + state = { pageInfo: { page: 1, startCursor: null, endCursor: null, hasNextPage: true } }; + + mutations[types.SET_PAGE_CURSORS](state, NEW_CURSORS); + expect(state.pageInfo).toEqual({ ...NEW_CURSORS, page: 1 }); + }); + }); + + describe(`${types.CANCEL_IMPORT_SUCCESS}`, () => { + const payload = { repoId: 1 }; + + beforeEach(() => { + state = { + repositories: [ + { + importSource: { id: 1 }, + importedProject: { importStatus: STATUSES.NONE }, + }, + ], + }; + mutations[types.CANCEL_IMPORT_SUCCESS](state, payload); + }); + + it('updates project status', () => { + expect(state.repositories[0].importedProject.importStatus).toBe(STATUSES.CANCELED); + }); + }); }); 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 1b0253480e0..08c407cc4b4 100644 --- a/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js +++ b/spec/frontend/incidents_settings/components/incidents_settings_service_spec.js @@ -1,5 +1,5 @@ import AxiosMockAdapter from 'axios-mock-adapter'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { ERROR_MSG } from '~/incidents_settings/constants'; import IncidentsSettingsService from '~/incidents_settings/incidents_settings_service'; import axios from '~/lib/utils/axios_utils'; @@ -37,7 +37,7 @@ describe('IncidentsSettingsService', () => { mock.onPatch().reply(httpStatusCodes.BAD_REQUEST); return service.updateSettings({}).then(() => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringContaining(ERROR_MSG), }); }); diff --git a/spec/frontend/integrations/edit/components/dynamic_field_spec.js b/spec/frontend/integrations/edit/components/dynamic_field_spec.js index 5af0e272285..7589b04b0fd 100644 --- a/spec/frontend/integrations/edit/components/dynamic_field_spec.js +++ b/spec/frontend/integrations/edit/components/dynamic_field_spec.js @@ -7,11 +7,16 @@ import { mockField } from '../mock_data'; describe('DynamicField', () => { let wrapper; - const createComponent = (props, isInheriting = false) => { + const createComponent = (props, isInheriting = false, editable = true) => { wrapper = mount(DynamicField, { propsData: { ...mockField, ...props }, computed: { isInheriting: () => isInheriting, + propsSource: () => { + return { + editable, + }; + }, }, }); }; @@ -28,12 +33,14 @@ describe('DynamicField', () => { describe('template', () => { describe.each` - isInheriting | disabled | readonly | checkboxLabel - ${true} | ${'disabled'} | ${'readonly'} | ${undefined} - ${false} | ${undefined} | ${undefined} | ${'Custom checkbox label'} + isInheriting | editable | disabled | readonly | checkboxLabel + ${true} | ${true} | ${'disabled'} | ${'readonly'} | ${undefined} + ${false} | ${true} | ${undefined} | ${undefined} | ${'Custom checkbox label'} + ${true} | ${false} | ${'disabled'} | ${'readonly'} | ${undefined} + ${false} | ${false} | ${'disabled'} | ${undefined} | ${'Custom checkbox label'} `( - 'dynamic field, when isInheriting = `%p`', - ({ isInheriting, disabled, readonly, checkboxLabel }) => { + 'dynamic field, when isInheriting = `$isInheriting` and editable = `$editable`', + ({ isInheriting, editable, disabled, readonly, checkboxLabel }) => { describe('type is checkbox', () => { beforeEach(() => { createComponent( @@ -42,6 +49,7 @@ describe('DynamicField', () => { checkboxLabel, }, isInheriting, + editable, ); }); @@ -74,6 +82,7 @@ describe('DynamicField', () => { ], }, isInheriting, + editable, ); }); @@ -97,6 +106,7 @@ describe('DynamicField', () => { type: 'textarea', }, isInheriting, + editable, ); }); @@ -119,6 +129,7 @@ describe('DynamicField', () => { type: 'password', }, isInheriting, + editable, ); }); @@ -143,6 +154,7 @@ describe('DynamicField', () => { required: true, }, isInheriting, + editable, ); }); @@ -204,7 +216,7 @@ describe('DynamicField', () => { }); expect(findGlFormGroup().find('small').html()).toContain( - '[<code>1</code> <a>3</a> <a href="foo">4</a>]', + '[<code>1</code> <a>3</a> <a href="foo" target="_blank" rel="noopener noreferrer">4</a>', ); }); }); diff --git a/spec/frontend/integrations/edit/components/integration_form_actions_spec.js b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js new file mode 100644 index 00000000000..e95e30a1899 --- /dev/null +++ b/spec/frontend/integrations/edit/components/integration_form_actions_spec.js @@ -0,0 +1,227 @@ +import { nextTick } from 'vue'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; +import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; +import IntegrationFormActions from '~/integrations/edit/components/integration_form_actions.vue'; + +import { integrationLevels } from '~/integrations/constants'; +import { createStore } from '~/integrations/edit/store'; +import { mockIntegrationProps } from '../mock_data'; + +describe('IntegrationFormActions', () => { + let wrapper; + + const createComponent = ({ customStateProps = {} } = {}) => { + const store = createStore({ + customState: { ...mockIntegrationProps, ...customStateProps }, + }); + jest.spyOn(store, 'dispatch'); + + wrapper = shallowMountExtended(IntegrationFormActions, { + store, + propsData: { + hasSections: false, + }, + }); + }; + + const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); + const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); + const findResetButton = () => wrapper.findByTestId('reset-button'); + const findSaveButton = () => wrapper.findByTestId('save-button'); + const findTestButton = () => wrapper.findByTestId('test-button'); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + + describe('ConfirmationModal', () => { + it.each` + desc | integrationLevel | shouldRender + ${'Should'} | ${integrationLevels.INSTANCE} | ${true} + ${'Should'} | ${integrationLevels.GROUP} | ${true} + ${'Should not'} | ${integrationLevels.PROJECT} | ${false} + `( + '$desc render the ConfirmationModal when integrationLevel is "$integrationLevel"', + ({ integrationLevel, shouldRender }) => { + createComponent({ + customStateProps: { + integrationLevel, + }, + }); + expect(findConfirmationModal().exists()).toBe(shouldRender); + }, + ); + }); + + describe('ResetConfirmationModal', () => { + it.each` + desc | integrationLevel | resetPath | shouldRender + ${'Should not'} | ${integrationLevels.INSTANCE} | ${''} | ${false} + ${'Should not'} | ${integrationLevels.GROUP} | ${''} | ${false} + ${'Should not'} | ${integrationLevels.PROJECT} | ${''} | ${false} + ${'Should'} | ${integrationLevels.INSTANCE} | ${'resetPath'} | ${true} + ${'Should'} | ${integrationLevels.GROUP} | ${'resetPath'} | ${true} + ${'Should not'} | ${integrationLevels.PROJECT} | ${'resetPath'} | ${false} + `( + '$desc render the ResetConfirmationModal modal when integrationLevel="$integrationLevel" and resetPath="$resetPath"', + ({ integrationLevel, resetPath, shouldRender }) => { + createComponent({ + customStateProps: { + integrationLevel, + resetPath, + }, + }); + expect(findResetConfirmationModal().exists()).toBe(shouldRender); + }, + ); + }); + + describe('Buttons rendering', () => { + it.each` + integrationLevel | canTest | resetPath | saveBtn | testBtn | cancelBtn | resetBtn + ${integrationLevels.PROJECT} | ${true} | ${'resetPath'} | ${true} | ${true} | ${true} | ${false} + ${integrationLevels.PROJECT} | ${false} | ${'resetPath'} | ${true} | ${false} | ${true} | ${false} + ${integrationLevels.PROJECT} | ${true} | ${''} | ${true} | ${true} | ${true} | ${false} + ${integrationLevels.GROUP} | ${true} | ${'resetPath'} | ${true} | ${true} | ${true} | ${true} + ${integrationLevels.GROUP} | ${false} | ${'resetPath'} | ${true} | ${false} | ${true} | ${true} + ${integrationLevels.GROUP} | ${true} | ${''} | ${true} | ${true} | ${true} | ${false} + ${integrationLevels.INSTANCE} | ${true} | ${'resetPath'} | ${true} | ${true} | ${true} | ${true} + ${integrationLevels.INSTANCE} | ${false} | ${'resetPath'} | ${true} | ${false} | ${true} | ${true} + ${integrationLevels.INSTANCE} | ${true} | ${''} | ${true} | ${true} | ${true} | ${false} + `( + 'on $integrationLevel when canTest="$canTest" and resetPath="$resetPath"', + ({ integrationLevel, canTest, resetPath, saveBtn, testBtn, cancelBtn, resetBtn }) => { + createComponent({ + customStateProps: { + integrationLevel, + canTest, + resetPath, + }, + }); + + expect(findSaveButton().exists()).toBe(saveBtn); + expect(findTestButton().exists()).toBe(testBtn); + expect(findCancelButton().exists()).toBe(cancelBtn); + expect(findResetButton().exists()).toBe(resetBtn); + }, + ); + }); + + describe('interactions', () => { + describe('Save button clicked', () => { + const createAndSave = (integrationLevel, withModal = false) => { + createComponent({ + customStateProps: { + integrationLevel, + canTest: true, + resetPath: 'resetPath', + }, + }); + + findSaveButton().vm.$emit('click', new Event('click')); + if (withModal) { + findConfirmationModal().vm.$emit('submit'); + } + wrapper.setProps({ + isSaving: true, + }); + }; + const sharedFormStateTest = async (integrationLevel, withModal = false) => { + createAndSave(integrationLevel, withModal); + + await nextTick(); + + const saveBtnWrapper = findSaveButton(); + const testBtnWrapper = findTestButton(); + const cancelBtnWrapper = findCancelButton(); + + expect(saveBtnWrapper.props('loading')).toBe(true); + expect(saveBtnWrapper.props('disabled')).toBe(true); + + expect(testBtnWrapper.props('loading')).toBe(false); + expect(testBtnWrapper.props('disabled')).toBe(true); + + expect(cancelBtnWrapper.props('loading')).toBe(false); + expect(cancelBtnWrapper.props('disabled')).toBe(true); + }; + + describe('on "project" level', () => { + const integrationLevel = integrationLevels.PROJECT; + it('emits the "save" event right away', async () => { + createAndSave(integrationLevel); + await nextTick(); + + expect(wrapper.emitted('save')).toHaveLength(1); + }); + + it('toggles the state of other buttons', async () => { + await sharedFormStateTest(integrationLevel); + + const resetBtnWrapper = findResetButton(); + expect(resetBtnWrapper.exists()).toBe(false); + }); + }); + + describe.each([integrationLevels.INSTANCE, integrationLevels.GROUP])( + 'on "%s" level', + (integrationLevel) => { + it('emits the "save" event only after the confirmation', () => { + createComponent({ + customStateProps: { + integrationLevel, + }, + }); + + findSaveButton().vm.$emit('click', new Event('click')); + expect(wrapper.emitted('save')).toBeUndefined(); + + findConfirmationModal().vm.$emit('submit'); + expect(wrapper.emitted('save')).toHaveLength(1); + }); + + it('toggles the state of other buttons', async () => { + await sharedFormStateTest(integrationLevel, true); + + const resetBtnWrapper = findResetButton(); + expect(resetBtnWrapper.props('loading')).toBe(false); + expect(resetBtnWrapper.props('disabled')).toBe(true); + }); + }, + ); + }); + + describe('Reset button clicked', () => { + describe.each([integrationLevels.INSTANCE, integrationLevels.GROUP])( + 'on "%s" level', + (integrationLevel) => { + it('emits the "reset" event only after the confirmation', () => { + createComponent({ + customStateProps: { + integrationLevel, + resetPath: 'resetPath', + }, + }); + + findResetButton().vm.$emit('click', new Event('click')); + expect(wrapper.emitted('reset')).toBeUndefined(); + + findResetConfirmationModal().vm.$emit('reset'); + expect(wrapper.emitted('reset')).toHaveLength(1); + }); + }, + ); + }); + + describe('Test button clicked', () => { + it('emits the "test" event when clicked', () => { + createComponent({ + customStateProps: { + integrationLevel: integrationLevels.PROJECT, + canTest: true, + }, + }); + + findTestButton().vm.$emit('click', new Event('click')); + expect(wrapper.emitted('test')).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/integrations/edit/components/integration_form_spec.js b/spec/frontend/integrations/edit/components/integration_form_spec.js index 7e67379f5ab..4b49e492880 100644 --- a/spec/frontend/integrations/edit/components/integration_form_spec.js +++ b/spec/frontend/integrations/edit/components/integration_form_spec.js @@ -1,21 +1,20 @@ import { GlAlert, GlBadge, GlForm } from '@gitlab/ui'; import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; +import { nextTick } from 'vue'; import * as Sentry from '@sentry/browser'; import { setHTMLFixture } from 'helpers/fixtures'; import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import ActiveCheckbox from '~/integrations/edit/components/active_checkbox.vue'; -import ConfirmationModal from '~/integrations/edit/components/confirmation_modal.vue'; import DynamicField from '~/integrations/edit/components/dynamic_field.vue'; import IntegrationForm from '~/integrations/edit/components/integration_form.vue'; import OverrideDropdown from '~/integrations/edit/components/override_dropdown.vue'; -import ResetConfirmationModal from '~/integrations/edit/components/reset_confirmation_modal.vue'; import TriggerFields from '~/integrations/edit/components/trigger_fields.vue'; import IntegrationSectionConnection from '~/integrations/edit/components/sections/connection.vue'; +import IntegrationFormActions from '~/integrations/edit/components/integration_form_actions.vue'; import { - integrationLevels, I18N_SUCCESSFUL_CONNECTION_MESSAGE, I18N_DEFAULT_ERROR_MESSAGE, INTEGRATION_FORM_TYPE_SLACK, @@ -60,7 +59,6 @@ describe('IntegrationForm', () => { stubs: { OverrideDropdown, ActiveCheckbox, - ConfirmationModal, TriggerFields, }, mocks: { @@ -73,12 +71,6 @@ describe('IntegrationForm', () => { const findOverrideDropdown = () => wrapper.findComponent(OverrideDropdown); const findActiveCheckbox = () => wrapper.findComponent(ActiveCheckbox); - const findConfirmationModal = () => wrapper.findComponent(ConfirmationModal); - const findResetConfirmationModal = () => wrapper.findComponent(ResetConfirmationModal); - const findResetButton = () => wrapper.findByTestId('reset-button'); - const findProjectSaveButton = () => wrapper.findByTestId('save-button'); - const findInstanceOrGroupSaveButton = () => wrapper.findByTestId('save-button-instance-group'); - const findTestButton = () => wrapper.findByTestId('test-button'); const findTriggerFields = () => wrapper.findComponent(TriggerFields); const findAlert = () => wrapper.findComponent(GlAlert); const findGlBadge = () => wrapper.findComponent(GlBadge); @@ -91,6 +83,7 @@ describe('IntegrationForm', () => { const findConnectionSectionComponent = () => findConnectionSection().findComponent(IntegrationSectionConnection); const findHelpHtml = () => wrapper.findByTestId('help-html'); + const findFormActions = () => wrapper.findComponent(IntegrationFormActions); beforeEach(() => { mockAxios = new MockAdapter(axios); @@ -102,108 +95,6 @@ describe('IntegrationForm', () => { }); describe('template', () => { - describe('integrationLevel is instance', () => { - it('renders ConfirmationModal', () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.INSTANCE, - }, - }); - - expect(findConfirmationModal().exists()).toBe(true); - }); - - describe('resetPath is empty', () => { - it('does not render ResetConfirmationModal and button', () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.INSTANCE, - }, - }); - - expect(findResetButton().exists()).toBe(false); - expect(findResetConfirmationModal().exists()).toBe(false); - }); - }); - - describe('resetPath is present', () => { - it('renders ResetConfirmationModal and button', () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.INSTANCE, - resetPath: 'resetPath', - }, - }); - - expect(findResetButton().exists()).toBe(true); - expect(findResetConfirmationModal().exists()).toBe(true); - }); - }); - }); - - describe('integrationLevel is group', () => { - it('renders ConfirmationModal', () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.GROUP, - }, - }); - - expect(findConfirmationModal().exists()).toBe(true); - }); - - describe('resetPath is empty', () => { - it('does not render ResetConfirmationModal and button', () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.GROUP, - }, - }); - - expect(findResetButton().exists()).toBe(false); - expect(findResetConfirmationModal().exists()).toBe(false); - }); - }); - - describe('resetPath is present', () => { - it('renders ResetConfirmationModal and button', () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.GROUP, - resetPath: 'resetPath', - }, - }); - - expect(findResetButton().exists()).toBe(true); - expect(findResetConfirmationModal().exists()).toBe(true); - }); - }); - }); - - describe('integrationLevel is project', () => { - it('does not render ConfirmationModal', () => { - createComponent({ - customStateProps: { - integrationLevel: 'project', - }, - }); - - expect(findConfirmationModal().exists()).toBe(false); - }); - - it('does not render ResetConfirmationModal and button', () => { - createComponent({ - customStateProps: { - integrationLevel: 'project', - resetPath: 'resetPath', - }, - }); - - expect(findResetButton().exists()).toBe(false); - expect(findResetConfirmationModal().exists()).toBe(false); - }); - }); - describe('triggerEvents is present', () => { it('renders TriggerFields', () => { const events = [{ title: 'push' }]; @@ -462,111 +353,85 @@ describe('IntegrationForm', () => { ); }); - describe('when `save` button is clicked', () => { - describe('buttons', () => { - beforeEach(async () => { - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: true, - }, - mountFn: mountExtended, - }); - - await findProjectSaveButton().vm.$emit('click', new Event('click')); - }); - - it('sets save button `loading` prop to `true`', () => { - expect(findProjectSaveButton().props('loading')).toBe(true); + describe('Response to the "save" event (form submission)', () => { + const prepareComponentAndSave = async (initialActivated = true, checkValidityReturn) => { + createComponent({ + customStateProps: { + showActive: true, + initialActivated, + fields: [mockField], + }, + mountFn: mountExtended, }); + jest.spyOn(findGlForm().element, 'submit'); + jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(checkValidityReturn); - it('sets test button `disabled` prop to `true`', () => { - expect(findTestButton().props('disabled')).toBe(true); - }); - }); + findFormActions().vm.$emit('save'); + await nextTick(); + }; - describe.each` - checkValidityReturn | integrationActive - ${true} | ${false} - ${true} | ${true} - ${false} | ${false} + it.each` + desc | checkValidityReturn | integrationActive | shouldSubmit + ${'form is valid'} | ${true} | ${false} | ${true} + ${'form is valid'} | ${true} | ${true} | ${true} + ${'form is invalid'} | ${false} | ${false} | ${true} + ${'form is invalid'} | ${false} | ${true} | ${false} `( - 'when form is valid (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', - ({ integrationActive, checkValidityReturn }) => { - beforeEach(async () => { - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: integrationActive, - }, - mountFn: mountExtended, - }); - jest.spyOn(findGlForm().element, 'submit'); - jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(checkValidityReturn); - - await findProjectSaveButton().vm.$emit('click', new Event('click')); - }); + 'when $desc (checkValidity returns $checkValidityReturn and integrationActive is $integrationActive)', + async ({ integrationActive, checkValidityReturn, shouldSubmit }) => { + await prepareComponentAndSave(integrationActive, checkValidityReturn); - it('submit form', () => { + if (shouldSubmit) { expect(findGlForm().element.submit).toHaveBeenCalledTimes(1); - }); + } else { + expect(findGlForm().element.submit).not.toHaveBeenCalled(); + } }, ); - describe('when form is invalid (checkValidity returns false and integrationActive is true)', () => { - beforeEach(async () => { - createComponent({ - customStateProps: { - showActive: true, - canTest: true, - initialActivated: true, - fields: [mockField], - }, - mountFn: mountExtended, - }); - jest.spyOn(findGlForm().element, 'submit'); - jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(false); - - await findProjectSaveButton().vm.$emit('click', new Event('click')); - }); - - it('does not submit form', () => { - expect(findGlForm().element.submit).not.toHaveBeenCalled(); - }); + it('flips `isSaving` to `true`', async () => { + await prepareComponentAndSave(true, true); + expect(findFormActions().props('isSaving')).toBe(true); + }); - it('sets save button `loading` prop to `false`', () => { - expect(findProjectSaveButton().props('loading')).toBe(false); + describe('when form is invalid', () => { + beforeEach(async () => { + await prepareComponentAndSave(true, false); }); - it('sets test button `disabled` prop to `false`', () => { - expect(findTestButton().props('disabled')).toBe(false); + it('when form is invalid, it sets `isValidated` props on form fields', () => { + expect(findDynamicField().props('isValidated')).toBe(true); }); - it('sets `isValidated` props on form fields', () => { - expect(findDynamicField().props('isValidated')).toBe(true); + it('resets `isSaving`', () => { + expect(findFormActions().props('isSaving')).toBe(false); }); }); }); - describe('when `test` button is clicked', () => { + describe('Response to the "test" event from the actions', () => { describe('when form is invalid', () => { - it('sets `isValidated` props on form fields', async () => { + beforeEach(async () => { createComponent({ customStateProps: { showActive: true, - canTest: true, fields: [mockField], }, mountFn: mountExtended, }); jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(false); - await findTestButton().vm.$emit('click', new Event('click')); + findFormActions().vm.$emit('test'); + await nextTick(); + }); + it('sets `isValidated` props on form fields', () => { expect(findDynamicField().props('isValidated')).toBe(true); }); + + it('resets `isTesting`', () => { + expect(findFormActions().props('isTesting')).toBe(false); + }); }); describe('when form is valid', () => { @@ -576,26 +441,18 @@ describe('IntegrationForm', () => { createComponent({ customStateProps: { showActive: true, - canTest: true, testPath: mockTestPath, }, mountFn: mountExtended, }); + jest.spyOn(findGlForm().element, 'checkValidity').mockReturnValue(true); }); - describe('buttons', () => { - beforeEach(async () => { - await findTestButton().vm.$emit('click', new Event('click')); - }); - - it('sets test button `loading` prop to `true`', () => { - expect(findTestButton().props('loading')).toBe(true); - }); - - it('sets save button `disabled` prop to `true`', () => { - expect(findProjectSaveButton().props('disabled')).toBe(true); - }); + it('flips `isTesting` to `true`', async () => { + findFormActions().vm.$emit('test'); + await nextTick(); + expect(findFormActions().props('isTesting')).toBe(true); }); describe.each` @@ -614,7 +471,7 @@ describe('IntegrationForm', () => { service_response: serviceResponse, }); - await findTestButton().vm.$emit('click', new Event('click')); + findFormActions().vm.$emit('test'); await waitForPromises(); }); @@ -622,14 +479,6 @@ describe('IntegrationForm', () => { expect(mockToastShow).toHaveBeenCalledWith(expectToast); }); - it('sets `loading` prop of test button to `false`', () => { - expect(findTestButton().props('loading')).toBe(false); - }); - - it('sets save button `disabled` prop to `false`', () => { - expect(findProjectSaveButton().props('disabled')).toBe(false); - }); - it(`${expectSentry ? 'does' : 'does not'} capture exception in Sentry`, () => { expect(Sentry.captureException).toHaveBeenCalledTimes(expectSentry ? 1 : 0); }); @@ -638,44 +487,27 @@ describe('IntegrationForm', () => { }); }); - describe('when `reset-confirmation-modal` emits `reset` event', () => { + describe('Response to the "reset" event from the actions', () => { const mockResetPath = '/reset'; - describe('buttons', () => { - beforeEach(async () => { - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.GROUP, - canTest: true, - resetPath: mockResetPath, - }, - }); - - await findResetConfirmationModal().vm.$emit('reset'); + beforeEach(async () => { + mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); + createComponent({ + customStateProps: { + resetPath: mockResetPath, + }, }); - it('sets reset button `loading` prop to `true`', () => { - expect(findResetButton().props('loading')).toBe(true); - }); + findFormActions().vm.$emit('reset'); + await nextTick(); + }); - it('sets other button `disabled` props to `true`', () => { - expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(true); - expect(findTestButton().props('disabled')).toBe(true); - }); + it('flips `isResetting` to `true`', () => { + expect(findFormActions().props('isResetting')).toBe(true); }); describe('when "reset settings" request fails', () => { beforeEach(async () => { - mockAxios.onPost(mockResetPath).replyOnce(httpStatus.INTERNAL_SERVER_ERROR); - createComponent({ - customStateProps: { - integrationLevel: integrationLevels.GROUP, - canTest: true, - resetPath: mockResetPath, - }, - }); - - await findResetConfirmationModal().vm.$emit('reset'); await waitForPromises(); }); @@ -687,13 +519,8 @@ describe('IntegrationForm', () => { expect(Sentry.captureException).toHaveBeenCalledTimes(1); }); - it('sets reset button `loading` prop to `false`', () => { - expect(findResetButton().props('loading')).toBe(false); - }); - - it('sets button `disabled` props to `false`', () => { - expect(findInstanceOrGroupSaveButton().props('disabled')).toBe(false); - expect(findTestButton().props('disabled')).toBe(false); + it('resets `isResetting`', () => { + expect(findFormActions().props('isResetting')).toBe(false); }); }); @@ -702,64 +529,74 @@ describe('IntegrationForm', () => { mockAxios.onPost(mockResetPath).replyOnce(httpStatus.OK); createComponent({ customStateProps: { - integrationLevel: integrationLevels.GROUP, resetPath: mockResetPath, }, }); - await findResetConfirmationModal().vm.$emit('reset'); + findFormActions().vm.$emit('reset'); await waitForPromises(); }); it('calls `refreshCurrentPage`', () => { expect(refreshCurrentPage).toHaveBeenCalledTimes(1); }); - }); - describe('Slack integration', () => { - describe('Help and sections rendering', () => { - const dummyHelp = 'Foo Help'; - - it.each` - integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp - ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false} - ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true} - ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false} - ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true} - ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false} - ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true} - ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} - ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true} - ${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false} - ${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true} - ${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} - ${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false} - ${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false} - ${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true} - ${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} - ${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false} - `( - '$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration', - ({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => { - createComponent({ - provide: { - helpHtml, - glFeatures: { integrationSlackAppNotifications: flagIsOn }, - }, - customStateProps: { - sections, - type: integration, - }, - }); - expect(findAllSections().length > 0).toEqual(shouldShowSections); - expect(findHelpHtml().exists()).toBe(shouldShowHelp); - if (shouldShowHelp) { - expect(findHelpHtml().html()).toContain(helpHtml); - } - }, - ); + it('resets `isResetting`', async () => { + expect(findFormActions().props('isResetting')).toBe(false); }); + }); + }); + + describe('Slack integration', () => { + describe('Help and sections rendering', () => { + const dummyHelp = 'Foo Help'; + + it.each` + integration | flagIsOn | helpHtml | sections | shouldShowSections | shouldShowHelp + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${''} | ${[]} | ${false} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${false} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${false} | ${true} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${''} | ${[]} | ${false} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} + ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${true} + ${'foo'} | ${false} | ${''} | ${[]} | ${false} | ${false} + ${'foo'} | ${false} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${'foo'} | ${false} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} + ${'foo'} | ${false} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false} + ${'foo'} | ${true} | ${''} | ${[]} | ${false} | ${false} + ${'foo'} | ${true} | ${dummyHelp} | ${[]} | ${false} | ${true} + ${'foo'} | ${true} | ${undefined} | ${[mockSectionConnection]} | ${true} | ${false} + ${'foo'} | ${true} | ${dummyHelp} | ${[mockSectionConnection]} | ${true} | ${false} + `( + '$sections sections, and "$helpHtml" helpHtml when the FF is "$flagIsOn" for "$integration" integration', + ({ integration, flagIsOn, helpHtml, sections, shouldShowSections, shouldShowHelp }) => { + createComponent({ + provide: { + helpHtml, + glFeatures: { integrationSlackAppNotifications: flagIsOn }, + }, + customStateProps: { + sections, + type: integration, + }, + }); + expect(findAllSections().length > 0).toEqual(shouldShowSections); + expect(findHelpHtml().exists()).toBe(shouldShowHelp); + if (shouldShowHelp) { + expect(findHelpHtml().html()).toContain(helpHtml); + } + }, + ); + }); + describe.each` + hasSections | hasFieldsWithoutSections | description + ${true} | ${true} | ${'When having both: the sections and the fields without a section'} + ${true} | ${false} | ${'When having the sections only'} + ${false} | ${true} | ${'When having only the fields without a section'} + `('$description', ({ hasSections, hasFieldsWithoutSections }) => { it.each` prefix | integration | shouldUpgradeSlack | flagIsOn | shouldShowAlert ${'does'} | ${INTEGRATION_FORM_TYPE_SLACK} | ${true} | ${true} | ${true} @@ -769,7 +606,7 @@ describe('IntegrationForm', () => { ${'does not'} | ${'foo'} | ${false} | ${true} | ${false} ${'does not'} | ${'foo'} | ${true} | ${false} | ${false} `( - '$prefix render the upgrade warnning when we are in "$integration" integration with the flag "$flagIsOn" and Slack-needs-upgrade is "$shouldUpgradeSlack"', + '$prefix render the upgrade warning when we are in "$integration" integration with the flag "$flagIsOn" and Slack-needs-upgrade is "$shouldUpgradeSlack" and have sections', ({ integration, shouldUpgradeSlack, flagIsOn, shouldShowAlert }) => { createComponent({ provide: { @@ -778,7 +615,8 @@ describe('IntegrationForm', () => { customStateProps: { shouldUpgradeSlack, type: integration, - sections: [mockSectionConnection], + sections: hasSections ? [mockSectionConnection] : [], + fields: hasFieldsWithoutSections ? [mockField] : [], }, }); expect(findAlert().exists()).toBe(shouldShowAlert); diff --git a/spec/frontend/invite_members/components/import_project_members_modal_spec.js b/spec/frontend/invite_members/components/import_project_members_modal_spec.js index 8b2d13be309..d839cde163c 100644 --- a/spec/frontend/invite_members/components/import_project_members_modal_spec.js +++ b/spec/frontend/invite_members/components/import_project_members_modal_spec.js @@ -8,6 +8,12 @@ import * as ProjectsApi from '~/api/projects_api'; import ImportProjectMembersModal from '~/invite_members/components/import_project_members_modal.vue'; import ProjectSelect from '~/invite_members/components/project_select.vue'; import axios from '~/lib/utils/axios_utils'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '~/invite_members/utils/trigger_successful_invite_alert'; + +jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); let wrapper; let mock; @@ -19,11 +25,12 @@ const $toast = { show: jest.fn(), }; -const createComponent = () => { +const createComponent = ({ props = {} } = {}) => { wrapper = shallowMountExtended(ImportProjectMembersModal, { propsData: { projectId, projectName, + ...props, }, stubs: { GlModal: stubComponent(GlModal, { @@ -101,6 +108,35 @@ describe('ImportProjectMembersModal', () => { }); describe('submitting the import', () => { + describe('when the import is successful with reloadPageOnSubmit', () => { + beforeEach(() => { + createComponent({ + props: { reloadPageOnSubmit: true }, + }); + + findProjectSelect().vm.$emit('input', projectToBeImported); + + jest.spyOn(ProjectsApi, 'importProjectMembers').mockResolvedValue(); + + clickImportButton(); + }); + + it('calls displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).toHaveBeenCalled(); + }); + + it('calls reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).toHaveBeenCalled(); + }); + + it('does not display the successful toastMessage', () => { + expect($toast.show).not.toHaveBeenCalledWith( + 'Successfully imported', + wrapper.vm.$options.toastOptions, + ); + }); + }); + describe('when the import is successful', () => { beforeEach(() => { createComponent(); @@ -126,6 +162,14 @@ describe('ImportProjectMembersModal', () => { ); }); + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); + it('sets isLoading to false after success', () => { expect(findGlModal().props('actionPrimary').attributes.loading).toBe(false); }); diff --git a/spec/frontend/invite_members/components/invite_group_notification_spec.js b/spec/frontend/invite_members/components/invite_group_notification_spec.js new file mode 100644 index 00000000000..3e6ba6da9f4 --- /dev/null +++ b/spec/frontend/invite_members/components/invite_group_notification_spec.js @@ -0,0 +1,42 @@ +import { GlAlert, GlLink, GlSprintf } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { sprintf } from '~/locale'; +import InviteGroupNotification from '~/invite_members/components/invite_group_notification.vue'; +import { GROUP_MODAL_ALERT_BODY } from '~/invite_members/constants'; + +describe('InviteGroupNotification', () => { + let wrapper; + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + const createComponent = () => { + wrapper = shallowMountExtended(InviteGroupNotification, { + provide: { freeUsersLimit: 5 }, + propsData: { name: 'name' }, + stubs: { GlSprintf }, + }); + }; + + describe('when rendering', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes the correct props', () => { + expect(findAlert().props()).toMatchObject({ variant: 'warning', dismissible: false }); + }); + + it('shows the correct message', () => { + const message = sprintf(GROUP_MODAL_ALERT_BODY, { count: 5 }); + + expect(findAlert().text()).toMatchInterpolatedText(message); + }); + + it('has a help link', () => { + expect(findLink().attributes('href')).toEqual( + 'https://docs.gitlab.com/ee/user/group/manage.html#share-a-group-with-another-group', + ); + }); + }); +}); diff --git a/spec/frontend/invite_members/components/invite_groups_modal_spec.js b/spec/frontend/invite_members/components/invite_groups_modal_spec.js index f9cb4a149f2..c2a55517405 100644 --- a/spec/frontend/invite_members/components/invite_groups_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_groups_modal_spec.js @@ -6,9 +6,16 @@ import InviteGroupsModal from '~/invite_members/components/invite_groups_modal.v import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; import GroupSelect from '~/invite_members/components/group_select.vue'; +import InviteGroupNotification from '~/invite_members/components/invite_group_notification.vue'; import { stubComponent } from 'helpers/stub_component'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '~/invite_members/utils/trigger_successful_invite_alert'; import { propsData, sharedGroup } from '../mock_data/group_modal'; +jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); + describe('InviteGroupsModal', () => { let wrapper; @@ -44,6 +51,7 @@ describe('InviteGroupsModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findGroupSelect = () => wrapper.findComponent(GroupSelect); + const findInviteGroupAlert = () => wrapper.findComponent(InviteGroupNotification); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); const findMembersFormGroup = () => wrapper.findByTestId('members-form-group'); const membersFormGroupInvalidFeedback = () => @@ -74,6 +82,20 @@ describe('InviteGroupsModal', () => { }); }); + describe('rendering the invite group notification', () => { + it('shows the user limit notification alert when free user cap is enabled', () => { + createComponent({ freeUserCapEnabled: true }); + + expect(findInviteGroupAlert().exists()).toBe(true); + }); + + it('does not show the user limit notification alert', () => { + createComponent(); + + expect(findInviteGroupAlert().exists()).toBe(false); + }); + }); + describe('submitting the invite form', () => { let apiResolve; let apiReject; @@ -126,6 +148,14 @@ describe('InviteGroupsModal', () => { onComplete: expect.any(Function), }); }); + + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); }); describe('when fails', () => { @@ -156,4 +186,37 @@ describe('InviteGroupsModal', () => { }); }); }); + + describe('submitting the invite form with reloadPageOnSubmit set true', () => { + const groupPostData = { + group_id: sharedGroup.id, + group_access: propsData.defaultAccessLevel, + expires_at: undefined, + format: 'json', + }; + + beforeEach(() => { + createComponent({ reloadPageOnSubmit: true }); + triggerGroupSelect(sharedGroup); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'groupShareWithGroup').mockResolvedValue({ data: groupPostData }); + + clickInviteButton(); + }); + + describe('when succeeds', () => { + it('calls displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).toHaveBeenCalled(); + }); + + it('calls reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).toHaveBeenCalled(); + }); + + it('does not show the toast message on failure', () => { + expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + }); + }); + }); }); diff --git a/spec/frontend/invite_members/components/invite_members_modal_spec.js b/spec/frontend/invite_members/components/invite_members_modal_spec.js index 47be1933ed7..22fcedb2eaf 100644 --- a/spec/frontend/invite_members/components/invite_members_modal_spec.js +++ b/spec/frontend/invite_members/components/invite_members_modal_spec.js @@ -19,13 +19,17 @@ import { MEMBERS_TO_PROJECT_CELEBRATE_INTRO_TEXT, LEARN_GITLAB, EXPANDED_ERRORS, - EMPTY_INVITES_ERROR_TEXT, + EMPTY_INVITES_ALERT_TEXT, } from '~/invite_members/constants'; import eventHub from '~/invite_members/event_hub'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; import axios from '~/lib/utils/axios_utils'; -import httpStatus from '~/lib/utils/http_status'; +import httpStatus, { HTTP_STATUS_CREATED } from '~/lib/utils/http_status'; import { getParameterValues } from '~/lib/utils/url_utility'; +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '~/invite_members/utils/trigger_successful_invite_alert'; import { GROUPS_INVITATIONS_PATH, invitationsApiResponse } from '../mock_data/api_responses'; import { propsData, @@ -40,6 +44,7 @@ import { GlEmoji, } from '../mock_data/member_modal'; +jest.mock('~/invite_members/utils/trigger_successful_invite_alert'); jest.mock('~/experimentation/experiment_tracking'); jest.mock('~/lib/utils/url_utility', () => ({ ...jest.requireActual('~/lib/utils/url_utility'), @@ -57,6 +62,7 @@ describe('InviteMembersModal', () => { }, propsData: { usersLimitDataset: {}, + fullPath: 'project', ...propsData, ...props, }, @@ -95,6 +101,7 @@ describe('InviteMembersModal', () => { const findModal = () => wrapper.findComponent(GlModal); const findBase = () => wrapper.findComponent(InviteModalBase); const findIntroText = () => wrapper.findByTestId('modal-base-intro-text').text(); + const findEmptyInvitesAlert = () => wrapper.findByTestId('empty-invites-alert'); const findMemberErrorAlert = () => wrapper.findByTestId('alert-member-error'); const findMoreInviteErrorsButton = () => wrapper.findByTestId('accordion-button'); const findUserLimitAlert = () => wrapper.findComponent(UserLimitNotification); @@ -397,7 +404,8 @@ describe('InviteMembersModal', () => { await waitForPromises(); - expect(membersFormGroupInvalidFeedback()).toBe(EMPTY_INVITES_ERROR_TEXT); + expect(findEmptyInvitesAlert().text()).toBe(EMPTY_INVITES_ALERT_TEXT); + expect(membersFormGroupInvalidFeedback()).toBe(MEMBERS_PLACEHOLDER); expect(findMembersSelect().props('exceptionState')).toBe(false); await triggerMembersTokenSelect([user1]); @@ -417,6 +425,29 @@ describe('InviteMembersModal', () => { tasks_project_id: '', }; + describe('when reloadOnSubmit is true', () => { + beforeEach(async () => { + createComponent({ reloadPageOnSubmit: true }); + await triggerMembersTokenSelect([user1, user2]); + + wrapper.vm.$toast = { show: jest.fn() }; + jest.spyOn(Api, 'inviteGroupMembers').mockResolvedValue({ data: postData }); + clickInviteButton(); + }); + + it('calls displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).toHaveBeenCalled(); + }); + + it('calls reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).toHaveBeenCalled(); + }); + + it('does not show the toast message', () => { + expect(wrapper.vm.$toast.show).not.toHaveBeenCalled(); + }); + }); + describe('when member is added successfully', () => { beforeEach(async () => { createComponent(); @@ -438,6 +469,14 @@ describe('InviteMembersModal', () => { it('displays the successful toastMessage', () => { expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); + + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); }); describe('when opened from a Learn GitLab page', () => { @@ -464,7 +503,7 @@ describe('InviteMembersModal', () => { describe('clearing the invalid state and message', () => { beforeEach(async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_TAKEN); + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_TAKEN); clickInviteButton(); @@ -523,7 +562,7 @@ describe('InviteMembersModal', () => { }); it('displays the restricted user api message for response with bad request', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED); + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED); clickInviteButton(); @@ -536,7 +575,7 @@ describe('InviteMembersModal', () => { }); it('displays all errors when there are multiple existing users that are restricted by email', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); @@ -590,6 +629,14 @@ describe('InviteMembersModal', () => { it('displays the successful toastMessage', () => { expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); + + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); }); }); @@ -633,7 +680,7 @@ describe('InviteMembersModal', () => { }); it('displays the restricted email error when restricted email is invited', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EMAIL_RESTRICTED); + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EMAIL_RESTRICTED); clickInviteButton(); @@ -647,7 +694,7 @@ describe('InviteMembersModal', () => { }); it('displays all errors when there are multiple emails that return a restricted error message', async () => { - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.MULTIPLE_RESTRICTED); clickInviteButton(); @@ -677,6 +724,14 @@ describe('InviteMembersModal', () => { expect(membersFormGroupInvalidFeedback()).toBe(expectedSyntaxError); expect(findMembersSelect().props('exceptionState')).toBe(false); }); + + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); }); describe('when multiple emails are invited at the same time', () => { @@ -698,7 +753,7 @@ describe('InviteMembersModal', () => { createInviteMembersToGroupWrapper(); await triggerMembersTokenSelect([user3, user4, user5, user6]); - mockInvitationsApi(httpStatus.CREATED, invitationsApiResponse.EXPANDED_RESTRICTED); + mockInvitationsApi(HTTP_STATUS_CREATED, invitationsApiResponse.EXPANDED_RESTRICTED); clickInviteButton(); @@ -791,6 +846,14 @@ describe('InviteMembersModal', () => { it('displays the successful toastMessage', () => { expect(wrapper.vm.$toast.show).toHaveBeenCalledWith('Members were successfully added'); }); + + it('does not call displaySuccessfulInvitationAlert on mount', () => { + expect(displaySuccessfulInvitationAlert).not.toHaveBeenCalled(); + }); + + it('does not call reloadOnInvitationSuccess', () => { + expect(reloadOnInvitationSuccess).not.toHaveBeenCalled(); + }); }); it('calls Apis with the invite source passed through to openModal', async () => { diff --git a/spec/frontend/invite_members/components/invite_modal_base_spec.js b/spec/frontend/invite_members/components/invite_modal_base_spec.js index aeead8809fd..db2afbbd141 100644 --- a/spec/frontend/invite_members/components/invite_modal_base_spec.js +++ b/spec/frontend/invite_members/components/invite_modal_base_spec.js @@ -1,16 +1,15 @@ import { - GlDropdown, - GlDropdownItem, + GlFormSelect, GlDatepicker, GlFormGroup, - GlSprintf, GlLink, + GlSprintf, GlModal, GlIcon, } from '@gitlab/ui'; import { stubComponent } from 'helpers/stub_component'; import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; -import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import InviteModalBase from '~/invite_members/components/invite_modal_base.vue'; import ContentTransition from '~/vue_shared/components/content_transition.vue'; @@ -26,24 +25,30 @@ import { propsData, membersPath, purchasePath } from '../mock_data/modal_base'; describe('InviteModalBase', () => { let wrapper; - const createComponent = (props = {}, stubs = {}) => { - wrapper = shallowMountExtended(InviteModalBase, { + const createComponent = ({ props = {}, stubs = {}, mountFn = shallowMountExtended } = {}) => { + const requiredStubs = + mountFn === mountExtended + ? {} + : { + ContentTransition, + GlFormSelect: true, + GlSprintf, + GlFormGroup: stubComponent(GlFormGroup, { + props: ['state', 'invalidFeedback'], + }), + }; + + wrapper = mountFn(InviteModalBase, { propsData: { ...propsData, ...props, }, stubs: { - ContentTransition, GlModal: stubComponent(GlModal, { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-footer"></slot></div>', }), - GlDropdown: true, - GlDropdownItem: true, - GlSprintf, - GlFormGroup: stubComponent(GlFormGroup, { - props: ['state', 'invalidFeedback'], - }), + ...requiredStubs, ...stubs, }, }); @@ -51,11 +56,10 @@ describe('InviteModalBase', () => { afterEach(() => { wrapper.destroy(); - wrapper = null; }); - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownItems = () => findDropdown().findAllComponents(GlDropdownItem); + const findFormSelect = () => wrapper.findComponent(GlFormSelect); + const findFormSelectOptions = () => findFormSelect().findAllComponents('option'); const findDatepicker = () => wrapper.findComponent(GlDatepicker); const findLink = () => wrapper.findComponent(GlLink); const findIcon = () => wrapper.findComponent(GlIcon); @@ -97,16 +101,29 @@ describe('InviteModalBase', () => { }); describe('rendering the access levels dropdown', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + }); + }); + it('sets the default dropdown text to the default access level name', () => { - expect(findDropdown().attributes('text')).toBe('Guest'); + expect(findFormSelect().exists()).toBe(true); + expect(findFormSelect().element.value).toBe('10'); }); it('renders dropdown items for each accessLevel', () => { - expect(findDropdownItems()).toHaveLength(5); + expect(findFormSelectOptions()).toHaveLength(5); }); }); describe('rendering the help link', () => { + beforeEach(() => { + createComponent({ + mountFn: mountExtended, + }); + }); + it('renders the correct link', () => { expect(findLink().attributes('href')).toBe(propsData.helpLink); }); @@ -126,7 +143,7 @@ describe('InviteModalBase', () => { }); it('renders description', () => { - createComponent({}, { GlFormGroup }); + createComponent({ stubs: { GlFormGroup } }); expect(findMembersFormGroup().attributes('description')).toContain( propsData.formGroupDescription, @@ -144,13 +161,16 @@ describe('InviteModalBase', () => { beforeEach(() => { createComponent( - { usersLimitDataset: { membersPath, purchasePath, reachedLimit: true } }, - { GlModal, GlFormGroup }, + { props: { usersLimitDataset: { membersPath, purchasePath, reachedLimit: true } } }, + { stubs: { GlModal, GlFormGroup } }, ); }); it('tracks actions', () => { - createComponent({ usersLimitDataset: { reachedLimit: true } }, { GlFormGroup, GlModal }); + createComponent({ + props: { usersLimitDataset: { reachedLimit: true } }, + stubs: { GlFormGroup, GlModal }, + }); trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); const modal = wrapper.findComponent(GlModal); @@ -164,8 +184,8 @@ describe('InviteModalBase', () => { describe('when user limit is close on a personal namespace', () => { beforeEach(() => { - createComponent( - { + createComponent({ + props: { usersLimitDataset: { membersPath, userNamespace: true, @@ -173,8 +193,8 @@ describe('InviteModalBase', () => { reachedLimit: false, }, }, - { GlModal, GlFormGroup }, - ); + stubs: { GlModal, GlFormGroup }, + }); }); it('renders correct buttons', () => { @@ -190,16 +210,16 @@ describe('InviteModalBase', () => { }); describe('when users limit is not reached', () => { - const textRegex = /Select a role.+Read more about role permissions Access expiration date \(optional\)/; + const textRegex = /Select a role\s*Read more about role permissions\s*Access expiration date \(optional\)/; beforeEach(() => { - createComponent({ reachedLimit: false }, { GlModal, GlFormGroup }); + createComponent({ props: { reachedLimit: false }, stubs: { GlModal, GlFormGroup } }); }); it('renders correct blocks', () => { expect(findIcon().exists()).toBe(false); expect(findDisabledInput().exists()).toBe(false); - expect(findDropdown().exists()).toBe(true); + expect(findFormSelect().exists()).toBe(true); expect(findDatepicker().exists()).toBe(true); expect(wrapper.findComponent(GlModal).text()).toMatch(textRegex); }); @@ -213,7 +233,9 @@ describe('InviteModalBase', () => { it('with isLoading, shows loading for invite button', () => { createComponent({ - isLoading: true, + props: { + isLoading: true, + }, }); expect(wrapper.findComponent(GlModal).props('actionPrimary').attributes.loading).toBe(true); @@ -221,7 +243,9 @@ describe('InviteModalBase', () => { it('with invalidFeedbackMessage, set members form group exception state', () => { createComponent({ - invalidFeedbackMessage: 'invalid message!', + props: { + invalidFeedbackMessage: 'invalid message!', + }, }); expect(findMembersFormGroup().props()).toEqual({ diff --git a/spec/frontend/invite_members/mock_data/group_modal.js b/spec/frontend/invite_members/mock_data/group_modal.js index c8588683885..65e8b025dd9 100644 --- a/spec/frontend/invite_members/mock_data/group_modal.js +++ b/spec/frontend/invite_members/mock_data/group_modal.js @@ -7,6 +7,8 @@ export const propsData = { accessLevels: { Guest: 10, Reporter: 20, Developer: 30, Maintainer: 40, Owner: 50 }, defaultAccessLevel: 10, helpLink: 'https://example.com', + fullPath: 'project', + freeUserCapEnabled: false, }; export const sharedGroup = { id: '981' }; diff --git a/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js b/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js new file mode 100644 index 00000000000..38b16dd0c2c --- /dev/null +++ b/spec/frontend/invite_members/utils/trigger_successful_invite_alert_spec.js @@ -0,0 +1,54 @@ +import { + displaySuccessfulInvitationAlert, + reloadOnInvitationSuccess, +} from '~/invite_members/utils/trigger_successful_invite_alert'; +import { + TOAST_MESSAGE_LOCALSTORAGE_KEY, + TOAST_MESSAGE_SUCCESSFUL, +} from '~/invite_members/constants'; +import { createAlert } from '~/flash'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; + +jest.mock('~/flash'); +useLocalStorageSpy(); + +describe('Display Successful Invitation Alert', () => { + it('does not show alert if localStorage key not present', () => { + localStorage.removeItem(TOAST_MESSAGE_LOCALSTORAGE_KEY); + + displaySuccessfulInvitationAlert(); + + expect(createAlert).not.toHaveBeenCalled(); + }); + + it('shows alert when localStorage key is present', () => { + localStorage.setItem(TOAST_MESSAGE_LOCALSTORAGE_KEY, 'true'); + + displaySuccessfulInvitationAlert(); + + expect(createAlert).toHaveBeenCalledWith({ + message: TOAST_MESSAGE_SUCCESSFUL, + variant: 'info', + }); + }); +}); + +describe('Reload On Invitation Success', () => { + const { location } = window; + + beforeAll(() => { + delete window.location; + window.location = { reload: jest.fn() }; + }); + + afterAll(() => { + window.location = location; + }); + + it('sets localStorage value and calls window.location.reload', () => { + reloadOnInvitationSuccess(); + + expect(localStorage.setItem).toHaveBeenCalledWith(TOAST_MESSAGE_LOCALSTORAGE_KEY, 'true'); + expect(window.location.reload).toHaveBeenCalled(); + }); +}); diff --git a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js index 3f72396cce6..3f40772f7fc 100644 --- a/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js +++ b/spec/frontend/issues/dashboard/components/issues_dashboard_app_spec.js @@ -1,58 +1,380 @@ import { GlEmptyState } from '@gitlab/ui'; +import * as Sentry from '@sentry/browser'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { cloneDeep } from 'lodash'; +import getIssuesQuery from 'ee_else_ce/issues/dashboard/queries/get_issues.query.graphql'; +import IssueCardStatistics from 'ee_else_ce/issues/list/components/issue_card_statistics.vue'; +import IssueCardTimeInfo from 'ee_else_ce/issues/list/components/issue_card_time_info.vue'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { TEST_HOST } from 'helpers/test_constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { + filteredTokens, + locationSearch, + setSortPreferenceMutationResponse, + setSortPreferenceMutationResponseWithErrors, +} from 'jest/issues/list/mock_data'; import IssuesDashboardApp from '~/issues/dashboard/components/issues_dashboard_app.vue'; +import { CREATED_DESC, i18n, UPDATED_DESC, urlSortParams } from '~/issues/list/constants'; +import setSortPreferenceMutation from '~/issues/list/queries/set_sort_preference.mutation.graphql'; +import { getSortKey, getSortOptions } from '~/issues/list/utils'; +import axios from '~/lib/utils/axios_utils'; +import { scrollUp } from '~/lib/utils/scroll_utils'; +import { + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableStates } from '~/vue_shared/issuable/list/constants'; +import { emptyIssuesQueryResponse, issuesQueryResponse } from '../mock_data'; + +jest.mock('@sentry/browser'); +jest.mock('~/lib/utils/scroll_utils', () => ({ scrollUp: jest.fn() })); describe('IssuesDashboardApp component', () => { + let axiosMock; let wrapper; + Vue.use(VueApollo); + const defaultProvide = { calendarPath: 'calendar/path', emptyStateSvgPath: 'empty-state.svg', + hasBlockedIssuesFeature: true, + hasIssuableHealthStatusFeature: true, + hasIssueWeightsFeature: true, + hasScopedLabelsFeature: true, + initialSort: CREATED_DESC, + isPublicVisibilityRestricted: false, isSignedIn: true, rssPath: 'rss/path', }; + let defaultQueryResponse = issuesQueryResponse; + if (IS_EE) { + defaultQueryResponse = cloneDeep(issuesQueryResponse); + defaultQueryResponse.data.issues.nodes[0].blockingCount = 1; + defaultQueryResponse.data.issues.nodes[0].healthStatus = null; + defaultQueryResponse.data.issues.nodes[0].weight = 5; + } + const findCalendarButton = () => wrapper.findByRole('link', { name: IssuesDashboardApp.i18n.calendarButtonText }); const findEmptyState = () => wrapper.findComponent(GlEmptyState); const findIssuableList = () => wrapper.findComponent(IssuableList); + const findIssueCardStatistics = () => wrapper.findComponent(IssueCardStatistics); + const findIssueCardTimeInfo = () => wrapper.findComponent(IssueCardTimeInfo); const findRssButton = () => wrapper.findByRole('link', { name: IssuesDashboardApp.i18n.rssButtonText }); - const mountComponent = () => { - wrapper = mountExtended(IssuesDashboardApp, { provide: defaultProvide }); + const mountComponent = ({ + provide = {}, + issuesQueryHandler = jest.fn().mockResolvedValue(defaultQueryResponse), + sortPreferenceMutationResponse = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse), + } = {}) => { + wrapper = mountExtended(IssuesDashboardApp, { + apolloProvider: createMockApollo([ + [getIssuesQuery, issuesQueryHandler], + [setSortPreferenceMutation, sortPreferenceMutationResponse], + ]), + provide: { + ...defaultProvide, + ...provide, + }, + }); }; beforeEach(() => { - mountComponent(); + setWindowLocation(TEST_HOST); + axiosMock = new AxiosMockAdapter(axios); }); - it('renders IssuableList component', () => { + afterEach(() => { + axiosMock.reset(); + }); + + it('renders IssuableList component', async () => { + mountComponent(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + expect(findIssuableList().props()).toMatchObject({ currentTab: IssuableStates.Opened, + hasNextPage: true, + hasPreviousPage: false, + hasScopedLabelsFeature: defaultProvide.hasScopedLabelsFeature, + initialSortBy: CREATED_DESC, + issuables: issuesQueryResponse.data.issues.nodes, + issuablesLoading: false, namespace: 'dashboard', recentSearchesStorageKey: 'issues', searchInputPlaceholder: IssuesDashboardApp.i18n.searchInputPlaceholder, + showPaginationControls: true, + sortOptions: getSortOptions({ + hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature, + }), tabs: IssuesDashboardApp.IssuableListTabs, + urlParams: { + sort: urlSortParams[CREATED_DESC], + state: IssuableStates.Opened, + }, + useKeysetPagination: true, }); }); it('renders RSS button link', () => { + mountComponent(); + expect(findRssButton().attributes('href')).toBe(defaultProvide.rssPath); expect(findRssButton().props('icon')).toBe('rss'); }); it('renders calendar button link', () => { + mountComponent(); + expect(findCalendarButton().attributes('href')).toBe(defaultProvide.calendarPath); expect(findCalendarButton().props('icon')).toBe('calendar'); }); - it('renders empty state', () => { + it('renders issue time information', async () => { + mountComponent(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(findIssueCardTimeInfo().exists()).toBe(true); + }); + + it('renders issue statistics', async () => { + mountComponent(); + jest.runOnlyPendingTimers(); + await waitForPromises(); + + expect(findIssueCardStatistics().exists()).toBe(true); + }); + + it('renders empty state', async () => { + mountComponent({ issuesQueryHandler: jest.fn().mockResolvedValue(emptyIssuesQueryResponse) }); + await waitForPromises(); + expect(findEmptyState().props()).toMatchObject({ svgPath: defaultProvide.emptyStateSvgPath, title: IssuesDashboardApp.i18n.emptyStateTitle, }); }); + + describe('initial url params', () => { + describe('search', () => { + it('is set from the url params', () => { + setWindowLocation(locationSearch); + mountComponent(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ search: 'find issues' }); + }); + }); + + describe('sort', () => { + describe('when initial sort value uses old enum values', () => { + const oldEnumSortValues = Object.values(urlSortParams); + + it.each(oldEnumSortValues)('initial sort is set with value %s', (sort) => { + mountComponent({ provide: { initialSort: sort } }); + + expect(findIssuableList().props('initialSortBy')).toBe(getSortKey(sort)); + }); + }); + + describe('when initial sort value uses new GraphQL enum values', () => { + const graphQLEnumSortValues = Object.keys(urlSortParams); + + it.each(graphQLEnumSortValues)('initial sort is set with value %s', (sort) => { + mountComponent({ provide: { initialSort: sort.toLowerCase() } }); + + expect(findIssuableList().props('initialSortBy')).toBe(sort); + }); + }); + + describe('when initial sort value is invalid', () => { + it.each(['', 'asdf', null, undefined])( + 'initial sort is set to value CREATED_DESC', + (sort) => { + mountComponent({ provide: { initialSort: sort } }); + + expect(findIssuableList().props('initialSortBy')).toBe(CREATED_DESC); + }, + ); + }); + }); + + describe('state', () => { + it('is set from the url params', () => { + const initialState = IssuableStates.All; + setWindowLocation(`?state=${initialState}`); + mountComponent(); + + expect(findIssuableList().props('currentTab')).toBe(initialState); + }); + }); + + describe('filter tokens', () => { + it('is set from the url params', () => { + setWindowLocation(locationSearch); + mountComponent(); + + expect(findIssuableList().props('initialFilterValue')).toEqual(filteredTokens); + }); + }); + }); + + describe('when there is an error fetching issues', () => { + beforeEach(() => { + mountComponent({ issuesQueryHandler: jest.fn().mockRejectedValue(new Error('ERROR')) }); + jest.runOnlyPendingTimers(); + return waitForPromises(); + }); + + it('shows an error message', () => { + expect(findIssuableList().props('error')).toBe(i18n.errorFetchingIssues); + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('ERROR')); + }); + + it('clears error message when "dismiss-alert" event is emitted from IssuableList', async () => { + findIssuableList().vm.$emit('dismiss-alert'); + await nextTick(); + + expect(findIssuableList().props('error')).toBeNull(); + }); + }); + + describe('tokens', () => { + const mockCurrentUser = { + id: 1, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }; + const originalGon = window.gon; + + beforeEach(() => { + window.gon = { + ...originalGon, + current_user_id: mockCurrentUser.id, + current_user_fullname: mockCurrentUser.name, + current_username: mockCurrentUser.username, + current_user_avatar_url: mockCurrentUser.avatar_url, + }; + mountComponent(); + }); + + afterEach(() => { + window.gon = originalGon; + }); + + it('renders all tokens alphabetically', () => { + const preloadedUsers = [{ ...mockCurrentUser, id: mockCurrentUser.id }]; + + expect(findIssuableList().props('searchTokens')).toMatchObject([ + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, + { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, + ]); + }); + }); + + describe('events', () => { + describe('when "click-tab" event is emitted by IssuableList', () => { + beforeEach(() => { + mountComponent(); + + findIssuableList().vm.$emit('click-tab', IssuableStates.Closed); + }); + + it('updates ui to the new tab', () => { + expect(findIssuableList().props('currentTab')).toBe(IssuableStates.Closed); + }); + + it('updates url to the new tab', () => { + expect(findIssuableList().props('urlParams')).toMatchObject({ + state: IssuableStates.Closed, + }); + }); + }); + + describe.each(['next-page', 'previous-page'])( + 'when "%s" event is emitted by IssuableList', + (event) => { + beforeEach(() => { + mountComponent(); + + findIssuableList().vm.$emit(event); + }); + + it('scrolls to the top', () => { + expect(scrollUp).toHaveBeenCalled(); + }); + }, + ); + + describe('when "sort" event is emitted by IssuableList', () => { + it.each(Object.keys(urlSortParams))( + 'updates to the new sort when payload is `%s`', + async (sortKey) => { + // Ensure initial sort key is different so we can trigger an update when emitting a sort key + if (sortKey === CREATED_DESC) { + mountComponent({ provide: { initialSort: UPDATED_DESC } }); + } else { + mountComponent(); + } + + findIssuableList().vm.$emit('sort', sortKey); + await nextTick(); + + expect(findIssuableList().props('urlParams')).toMatchObject({ + sort: urlSortParams[sortKey], + }); + }, + ); + + describe('when user is signed in', () => { + it('calls mutation to save sort preference', () => { + const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); + mountComponent({ sortPreferenceMutationResponse: mutationMock }); + + findIssuableList().vm.$emit('sort', UPDATED_DESC); + + expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: UPDATED_DESC } }); + }); + + it('captures error when mutation response has errors', async () => { + const mutationMock = jest + .fn() + .mockResolvedValue(setSortPreferenceMutationResponseWithErrors); + mountComponent({ sortPreferenceMutationResponse: mutationMock }); + + findIssuableList().vm.$emit('sort', UPDATED_DESC); + await waitForPromises(); + + expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); + }); + }); + + describe('when user is signed out', () => { + it('does not call mutation to save sort preference', () => { + const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); + mountComponent({ + provide: { isSignedIn: false }, + sortPreferenceMutationResponse: mutationMock, + }); + + findIssuableList().vm.$emit('sort', CREATED_DESC); + + expect(mutationMock).not.toHaveBeenCalled(); + }); + }); + }); + }); }); diff --git a/spec/frontend/issues/dashboard/mock_data.js b/spec/frontend/issues/dashboard/mock_data.js new file mode 100644 index 00000000000..feb4cb80bd8 --- /dev/null +++ b/spec/frontend/issues/dashboard/mock_data.js @@ -0,0 +1,88 @@ +export const issuesQueryResponse = { + data: { + issues: { + nodes: [ + { + __typename: 'Issue', + id: 'gid://gitlab/Issue/123456', + iid: '789', + closedAt: null, + confidential: false, + createdAt: '2021-05-22T04:08:01Z', + downvotes: 2, + dueDate: '2021-05-29', + hidden: false, + humanTimeEstimate: null, + mergeRequestsCount: false, + moved: false, + reference: 'group/project#123456', + state: 'opened', + title: 'Issue title', + type: 'issue', + updatedAt: '2021-05-22T04:08:01Z', + upvotes: 3, + userDiscussionsCount: 4, + webPath: 'project/-/issues/789', + webUrl: 'project/-/issues/789', + assignees: { + nodes: [ + { + __typename: 'UserCore', + id: 'gid://gitlab/User/234', + avatarUrl: 'avatar/url', + name: 'Marge Simpson', + username: 'msimpson', + webUrl: 'url/msimpson', + }, + ], + }, + author: { + __typename: 'UserCore', + id: 'gid://gitlab/User/456', + avatarUrl: 'avatar/url', + name: 'Homer Simpson', + username: 'hsimpson', + webUrl: 'url/hsimpson', + }, + labels: { + nodes: [ + { + id: 'gid://gitlab/ProjectLabel/456', + color: '#333', + title: 'Label title', + description: 'Label description', + }, + ], + }, + milestone: null, + taskCompletionStatus: { + completedCount: 1, + count: 2, + }, + }, + ], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: true, + hasPreviousPage: false, + startCursor: 'startcursor', + endCursor: 'endcursor', + }, + }, + }, +}; + +export const emptyIssuesQueryResponse = { + data: { + issues: { + nodes: [], + pageInfo: { + __typename: 'PageInfo', + hasNextPage: false, + hasPreviousPage: false, + startCursor: '', + endCursor: '', + }, + }, + }, +}; diff --git a/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js new file mode 100644 index 00000000000..d0d20ef03e1 --- /dev/null +++ b/spec/frontend/issues/list/components/empty_state_with_any_issues_spec.js @@ -0,0 +1,68 @@ +import { GlEmptyState } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue'; +import IssuesListApp from '~/issues/list/components/issues_list_app.vue'; + +describe('EmptyStateWithAnyIssues component', () => { + let wrapper; + + const defaultProvide = { + emptyStateSvgPath: 'empty/state/svg/path', + newIssuePath: 'new/issue/path', + showNewIssueLink: false, + }; + + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + + const mountComponent = (props = {}) => { + wrapper = shallowMount(EmptyStateWithAnyIssues, { + propsData: { + hasSearch: true, + isOpenTab: true, + ...props, + }, + provide: defaultProvide, + }); + }; + + describe('when there is a search (with no results)', () => { + beforeEach(() => { + mountComponent({ hasSearch: true }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noSearchResultsDescription, + title: IssuesListApp.i18n.noSearchResultsTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Open" tab is active', () => { + beforeEach(() => { + mountComponent({ hasSearch: false, isOpenTab: true }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + description: IssuesListApp.i18n.noOpenIssuesDescription, + title: IssuesListApp.i18n.noOpenIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); + + describe('when "Closed" tab is active', () => { + beforeEach(() => { + mountComponent({ hasSearch: false, isOpenTab: false }); + }); + + it('shows empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: IssuesListApp.i18n.noClosedIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + }); +}); diff --git a/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js new file mode 100644 index 00000000000..065139f10f4 --- /dev/null +++ b/spec/frontend/issues/list/components/empty_state_without_any_issues_spec.js @@ -0,0 +1,211 @@ +import { GlEmptyState, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; +import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue'; +import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; +import { i18n } from '~/issues/list/constants'; + +describe('EmptyStateWithoutAnyIssues component', () => { + let wrapper; + + const defaultProps = { + currentTabCount: 0, + exportCsvPathWithQuery: 'export/csv/path', + }; + + const defaultProvide = { + canCreateProjects: false, + emptyStateSvgPath: 'empty/state/svg/path', + fullPath: 'full/path', + isSignedIn: true, + jiraIntegrationPath: 'jira/integration/path', + newIssuePath: 'new/issue/path', + newProjectPath: 'new/project/path', + showNewIssueLink: false, + signInPath: 'sign/in/path', + }; + + const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); + const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); + const findGlLink = () => wrapper.findComponent(GlLink); + const findIssuesHelpPageLink = () => + wrapper.findByRole('link', { name: i18n.noIssuesDescription }); + const findJiraDocsLink = () => + wrapper.findByRole('link', { name: 'Enable the Jira integration' }); + const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown); + const findNewIssueLink = () => wrapper.findByRole('link', { name: i18n.newIssueLabel }); + const findNewProjectLink = () => wrapper.findByRole('link', { name: i18n.newProjectLabel }); + + const mountComponent = ({ props = {}, provide = {} } = {}) => { + wrapper = mountExtended(EmptyStateWithoutAnyIssues, { + propsData: { + ...defaultProps, + ...props, + }, + provide: { + ...defaultProvide, + ...provide, + }, + stubs: { + NewIssueDropdown: true, + }, + }); + }; + + describe('when signed in', () => { + describe('empty state', () => { + it('renders empty state', () => { + mountComponent(); + + expect(findGlEmptyState().props()).toMatchObject({ + title: i18n.noIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + }); + }); + + describe('description', () => { + it('renders issues docs link', () => { + mountComponent(); + + expect(findIssuesHelpPageLink().attributes('href')).toBe( + EmptyStateWithoutAnyIssues.issuesHelpPagePath, + ); + }); + + describe('"create a project first" description', () => { + describe('when can create projects', () => { + it('renders', () => { + mountComponent({ provide: { canCreateProjects: true } }); + + expect(findGlEmptyState().text()).toContain(i18n.noGroupIssuesSignedInDescription); + }); + }); + + describe('when cannot create projects', () => { + it('does not render', () => { + mountComponent({ provide: { canCreateProjects: false } }); + + expect(findGlEmptyState().text()).not.toContain( + i18n.noGroupIssuesSignedInDescription, + ); + }); + }); + }); + }); + + describe('actions', () => { + describe('"New project" link', () => { + describe('when can create projects', () => { + it('renders', () => { + mountComponent({ provide: { canCreateProjects: true } }); + + expect(findNewProjectLink().attributes('href')).toBe(defaultProvide.newProjectPath); + }); + }); + + describe('when cannot create projects', () => { + it('does not render', () => { + mountComponent({ provide: { canCreateProjects: false } }); + + expect(findNewProjectLink().exists()).toBe(false); + }); + }); + }); + + describe('"New issue" link', () => { + describe('when can show new issue link', () => { + it('renders', () => { + mountComponent({ provide: { showNewIssueLink: true } }); + + expect(findNewIssueLink().attributes('href')).toBe(defaultProvide.newIssuePath); + }); + }); + + describe('when cannot show new issue link', () => { + it('does not render', () => { + mountComponent({ provide: { showNewIssueLink: false } }); + + expect(findNewIssueLink().exists()).toBe(false); + }); + }); + }); + + describe('CSV import/export buttons', () => { + describe('when can show csv buttons', () => { + it('renders', () => { + mountComponent({ props: { showCsvButtons: true } }); + + expect(findCsvImportExportButtons().props()).toMatchObject({ + exportCsvPath: defaultProps.exportCsvPathWithQuery, + issuableCount: 0, + }); + }); + }); + + describe('when cannot show csv buttons', () => { + it('does not render', () => { + mountComponent({ props: { showCsvButtons: false } }); + + expect(findCsvImportExportButtons().exists()).toBe(false); + }); + }); + }); + + describe('new issue dropdown', () => { + describe('when can show new issue dropdown', () => { + it('renders', () => { + mountComponent({ props: { showNewIssueDropdown: true } }); + + expect(findNewIssueDropdown().exists()).toBe(true); + }); + }); + + describe('when cannot show new issue dropdown', () => { + it('does not render', () => { + mountComponent({ props: { showNewIssueDropdown: false } }); + + expect(findNewIssueDropdown().exists()).toBe(false); + }); + }); + }); + }); + }); + + describe('Jira section', () => { + beforeEach(() => { + mountComponent(); + }); + + it('shows Jira integration information', () => { + const paragraphs = wrapper.findAll('p'); + expect(paragraphs.at(1).text()).toContain(i18n.jiraIntegrationTitle); + expect(paragraphs.at(2).text()).toMatchInterpolatedText(i18n.jiraIntegrationMessage); + expect(paragraphs.at(3).text()).toContain(i18n.jiraIntegrationSecondaryMessage); + }); + + it('renders Jira integration docs link', () => { + expect(findJiraDocsLink().attributes('href')).toBe(defaultProvide.jiraIntegrationPath); + }); + }); + }); + + describe('when signed out', () => { + beforeEach(() => { + mountComponent({ provide: { isSignedIn: false } }); + }); + + it('renders empty state', () => { + expect(findGlEmptyState().props()).toMatchObject({ + title: i18n.noIssuesTitle, + svgPath: defaultProvide.emptyStateSvgPath, + primaryButtonText: i18n.noIssuesSignedOutButtonText, + primaryButtonLink: defaultProvide.signInPath, + }); + }); + + it('renders issues docs link', () => { + expect(findGlLink().attributes('href')).toBe(EmptyStateWithoutAnyIssues.issuesHelpPagePath); + expect(findGlLink().text()).toBe(i18n.noIssuesDescription); + }); + }); +}); diff --git a/spec/frontend/issues/list/components/issue_card_statistics_spec.js b/spec/frontend/issues/list/components/issue_card_statistics_spec.js new file mode 100644 index 00000000000..180d4ab7eb6 --- /dev/null +++ b/spec/frontend/issues/list/components/issue_card_statistics_spec.js @@ -0,0 +1,64 @@ +import { GlIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import IssueCardStatistics from '~/issues/list/components/issue_card_statistics.vue'; +import { i18n } from '~/issues/list/constants'; + +describe('IssueCardStatistics CE component', () => { + let wrapper; + + const findMergeRequests = () => wrapper.findByTestId('merge-requests'); + const findUpvotes = () => wrapper.findByTestId('issuable-upvotes'); + const findDownvotes = () => wrapper.findByTestId('issuable-downvotes'); + + const mountComponent = ({ mergeRequestsCount, upvotes, downvotes } = {}) => { + wrapper = shallowMountExtended(IssueCardStatistics, { + propsData: { + issue: { + mergeRequestsCount, + upvotes, + downvotes, + }, + }, + }); + }; + + describe('when issue attributes are undefined', () => { + it('does not render the attributes', () => { + mountComponent(); + + expect(findMergeRequests().exists()).toBe(false); + expect(findUpvotes().exists()).toBe(false); + expect(findDownvotes().exists()).toBe(false); + }); + }); + + describe('when issue attributes are defined', () => { + beforeEach(() => { + mountComponent({ mergeRequestsCount: 1, upvotes: 5, downvotes: 9 }); + }); + + it('renders merge requests', () => { + const mergeRequests = findMergeRequests(); + + expect(mergeRequests.text()).toBe('1'); + expect(mergeRequests.attributes('title')).toBe(i18n.relatedMergeRequests); + expect(mergeRequests.findComponent(GlIcon).props('name')).toBe('merge-request'); + }); + + it('renders upvotes', () => { + const upvotes = findUpvotes(); + + expect(upvotes.text()).toBe('5'); + expect(upvotes.attributes('title')).toBe(i18n.upvotes); + expect(upvotes.findComponent(GlIcon).props('name')).toBe('thumb-up'); + }); + + it('renders downvotes', () => { + const downvotes = findDownvotes(); + + expect(downvotes.text()).toBe('9'); + expect(downvotes.attributes('title')).toBe(i18n.downvotes); + expect(downvotes.findComponent(GlIcon).props('name')).toBe('thumb-down'); + }); + }); +}); diff --git a/spec/frontend/issues/list/components/issues_list_app_spec.js b/spec/frontend/issues/list/components/issues_list_app_spec.js index d0c93c896b3..4c5d8ce3cd1 100644 --- a/spec/frontend/issues/list/components/issues_list_app_spec.js +++ b/spec/frontend/issues/list/components/issues_list_app_spec.js @@ -1,4 +1,4 @@ -import { GlButton, GlEmptyState } from '@gitlab/ui'; +import { GlButton } from '@gitlab/ui'; import * as Sentry from '@sentry/browser'; import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -21,18 +21,21 @@ import { setSortPreferenceMutationResponseWithErrors, urlParams, } from 'jest/issues/list/mock_data'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_INFO } from '~/flash'; import { convertToGraphQLId, getIdFromGraphQLId } from '~/graphql_shared/utils'; import CsvImportExportButtons from '~/issuable/components/csv_import_export_buttons.vue'; import IssuableByEmail from '~/issuable/components/issuable_by_email.vue'; import IssuableList from '~/vue_shared/issuable/list/components/issuable_list_root.vue'; import { IssuableListTabs, IssuableStates } from '~/vue_shared/issuable/list/constants'; +import EmptyStateWithAnyIssues from '~/issues/list/components/empty_state_with_any_issues.vue'; +import EmptyStateWithoutAnyIssues from '~/issues/list/components/empty_state_without_any_issues.vue'; import IssuesListApp from '~/issues/list/components/issues_list_app.vue'; import NewIssueDropdown from '~/issues/list/components/new_issue_dropdown.vue'; import { CREATED_DESC, RELATIVE_POSITION, RELATIVE_POSITION_ASC, + UPDATED_DESC, urlSortParams, } from '~/issues/list/constants'; import eventHub from '~/issues/list/eventhub'; @@ -58,10 +61,11 @@ import { TOKEN_TYPE_MY_REACTION, TOKEN_TYPE_ORGANIZATION, TOKEN_TYPE_RELEASE, + TOKEN_TYPE_SEARCH_WITHIN, TOKEN_TYPE_TYPE, } from '~/vue_shared/components/filtered_search_bar/constants'; -import('~/issuable/bulk_update_sidebar'); +import('~/issuable'); import('~/users_select'); jest.mock('@sentry/browser'); @@ -122,10 +126,8 @@ describe('CE IssuesListApp component', () => { const findCsvImportExportButtons = () => wrapper.findComponent(CsvImportExportButtons); const findIssuableByEmail = () => wrapper.findComponent(IssuableByEmail); - const findGlButton = () => wrapper.findComponent(GlButton); const findGlButtons = () => wrapper.findAllComponents(GlButton); const findGlButtonAt = (index) => findGlButtons().at(index); - const findGlEmptyState = () => wrapper.findComponent(GlEmptyState); const findIssuableList = () => wrapper.findComponent(IssuableList); const findNewIssueDropdown = () => wrapper.findComponent(NewIssueDropdown); @@ -182,7 +184,11 @@ describe('CE IssuesListApp component', () => { namespace: defaultProvide.fullPath, recentSearchesStorageKey: 'issues', searchInputPlaceholder: IssuesListApp.i18n.searchPlaceholder, - sortOptions: getSortOptions(true, true), + sortOptions: getSortOptions({ + hasBlockedIssuesFeature: defaultProvide.hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature: defaultProvide.hasIssuableHealthStatusFeature, + hasIssueWeightsFeature: defaultProvide.hasIssueWeightsFeature, + }), initialSortBy: CREATED_DESC, issuables: getIssuesQueryResponse.data.project.issues.nodes, tabs: IssuableListTabs, @@ -395,9 +401,9 @@ describe('CE IssuesListApp component', () => { }); it('shows an alert to tell the user that manual reordering is disabled', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: IssuesListApp.i18n.issueRepositioningMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }); }); @@ -435,9 +441,9 @@ describe('CE IssuesListApp component', () => { }); it('shows an alert to tell the user they must be signed in to search', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: IssuesListApp.i18n.anonymousSearchingMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }); }); @@ -486,136 +492,29 @@ describe('CE IssuesListApp component', () => { describe('empty states', () => { describe('when there are issues', () => { - describe('when search returns no results', () => { - beforeEach(() => { - setWindowLocation(`?search=no+results`); - - wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - description: IssuesListApp.i18n.noSearchResultsDescription, - title: IssuesListApp.i18n.noSearchResultsTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); - }); - }); - - describe('when "Open" tab has no issues', () => { - beforeEach(() => { - wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - description: IssuesListApp.i18n.noOpenIssuesDescription, - title: IssuesListApp.i18n.noOpenIssuesTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); - }); + beforeEach(() => { + wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); }); - describe('when "Closed" tab has no issues', () => { - beforeEach(() => { - setWindowLocation(`?state=${IssuableStates.Closed}`); - - wrapper = mountComponent({ provide: { hasAnyIssues: true }, mountFn: mount }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: IssuesListApp.i18n.noClosedIssuesTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); + it('shows EmptyStateWithAnyIssues empty state', () => { + expect(wrapper.findComponent(EmptyStateWithAnyIssues).props()).toEqual({ + hasSearch: false, + isOpenTab: true, }); }); }); describe('when there are no issues', () => { - describe('when user is logged in', () => { - beforeEach(() => { - wrapper = mountComponent({ - provide: { hasAnyIssues: false, isSignedIn: true }, - mountFn: mount, - }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: IssuesListApp.i18n.noIssuesSignedInTitle, - svgPath: defaultProvide.emptyStateSvgPath, - }); - expect(findGlEmptyState().text()).toContain( - IssuesListApp.i18n.noIssuesSignedInDescription, - ); - }); - - it('shows "New issue" and import/export buttons', () => { - expect(findGlButton().text()).toBe(IssuesListApp.i18n.newIssueLabel); - expect(findGlButton().attributes('href')).toBe(defaultProvide.newIssuePath); - expect(findCsvImportExportButtons().props()).toMatchObject({ - exportCsvPath: defaultProvide.exportCsvPath, - issuableCount: 0, - }); - }); - - it('shows Jira integration information', () => { - const paragraphs = wrapper.findAll('p'); - const links = wrapper.findAll('.gl-link'); - expect(paragraphs.at(1).text()).toContain(IssuesListApp.i18n.jiraIntegrationTitle); - expect(paragraphs.at(2).text()).toContain( - 'Enable the Jira integration to view your Jira issues in GitLab.', - ); - expect(paragraphs.at(3).text()).toContain( - IssuesListApp.i18n.jiraIntegrationSecondaryMessage, - ); - expect(links.at(1).text()).toBe('Enable the Jira integration'); - expect(links.at(1).attributes('href')).toBe(defaultProvide.jiraIntegrationPath); - }); - }); - - describe('when user is logged in and can create projects', () => { - beforeEach(() => { - wrapper = mountComponent({ - provide: { canCreateProjects: true, hasAnyIssues: false, isSignedIn: true }, - stubs: { GlEmptyState }, - }); - }); - - it('shows empty state with additional description about creating projects', () => { - expect(findGlEmptyState().text()).toContain( - IssuesListApp.i18n.noIssuesSignedInDescription, - ); - expect(findGlEmptyState().text()).toContain( - IssuesListApp.i18n.noGroupIssuesSignedInDescription, - ); - }); - - it('shows "New project" button', () => { - expect(findGlButton().text()).toBe(IssuesListApp.i18n.newProjectLabel); - expect(findGlButton().attributes('href')).toBe(defaultProvide.newProjectPath); - }); + beforeEach(() => { + wrapper = mountComponent({ provide: { hasAnyIssues: false } }); }); - describe('when user is logged out', () => { - beforeEach(() => { - wrapper = mountComponent({ - provide: { hasAnyIssues: false, isSignedIn: false }, - mountFn: mount, - }); - }); - - it('shows empty state', () => { - expect(findGlEmptyState().props()).toMatchObject({ - title: IssuesListApp.i18n.noIssuesSignedOutTitle, - svgPath: defaultProvide.emptyStateSvgPath, - primaryButtonText: IssuesListApp.i18n.noIssuesSignedOutButtonText, - primaryButtonLink: defaultProvide.signInPath, - }); - expect(findGlEmptyState().text()).toContain( - IssuesListApp.i18n.noIssuesSignedOutDescription, - ); + it('shows EmptyStateWithoutAnyIssues empty state', () => { + expect(wrapper.findComponent(EmptyStateWithoutAnyIssues).props()).toEqual({ + currentTabCount: 0, + exportCsvPathWithQuery: defaultProvide.exportCsvPath, + showCsvButtons: true, + showNewIssueDropdown: false, }); }); }); @@ -636,8 +535,8 @@ describe('CE IssuesListApp component', () => { it('does not render My-Reaction or Confidential tokens', () => { expect(findIssuableList().props('searchTokens')).not.toMatchObject([ - { type: TOKEN_TYPE_AUTHOR, preloadedAuthors: [mockCurrentUser] }, - { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors: [mockCurrentUser] }, + { type: TOKEN_TYPE_AUTHOR, preloadedUsers: [mockCurrentUser] }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers: [mockCurrentUser] }, { type: TOKEN_TYPE_MY_REACTION }, { type: TOKEN_TYPE_CONFIDENTIAL }, ]); @@ -685,13 +584,13 @@ describe('CE IssuesListApp component', () => { }); it('renders all tokens alphabetically', () => { - const preloadedAuthors = [ + const preloadedUsers = [ { ...mockCurrentUser, id: convertToGraphQLId('User', mockCurrentUser.id) }, ]; expect(findIssuableList().props('searchTokens')).toMatchObject([ - { type: TOKEN_TYPE_ASSIGNEE, preloadedAuthors }, - { type: TOKEN_TYPE_AUTHOR, preloadedAuthors }, + { type: TOKEN_TYPE_ASSIGNEE, preloadedUsers }, + { type: TOKEN_TYPE_AUTHOR, preloadedUsers }, { type: TOKEN_TYPE_CONFIDENTIAL }, { type: TOKEN_TYPE_CONTACT }, { type: TOKEN_TYPE_LABEL }, @@ -699,6 +598,7 @@ describe('CE IssuesListApp component', () => { { type: TOKEN_TYPE_MY_REACTION }, { type: TOKEN_TYPE_ORGANIZATION }, { type: TOKEN_TYPE_RELEASE }, + { type: TOKEN_TYPE_SEARCH_WITHIN }, { type: TOKEN_TYPE_TYPE }, ]); }); @@ -899,7 +799,11 @@ describe('CE IssuesListApp component', () => { it.each(Object.keys(urlSortParams))( 'updates to the new sort when payload is `%s`', async (sortKey) => { - wrapper = mountComponent(); + // Ensure initial sort key is different so we can trigger an update when emitting a sort key + wrapper = + sortKey === CREATED_DESC + ? mountComponent({ provide: { initialSort: UPDATED_DESC } }) + : mountComponent(); router.push = jest.fn(); findIssuableList().vm.$emit('sort', sortKey); @@ -929,9 +833,9 @@ describe('CE IssuesListApp component', () => { }); it('shows an alert to tell the user that manual reordering is disabled', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: IssuesListApp.i18n.issueRepositioningMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }); }); @@ -941,9 +845,9 @@ describe('CE IssuesListApp component', () => { const mutationMock = jest.fn().mockResolvedValue(setSortPreferenceMutationResponse); wrapper = mountComponent({ sortPreferenceMutationResponse: mutationMock }); - findIssuableList().vm.$emit('sort', CREATED_DESC); + findIssuableList().vm.$emit('sort', UPDATED_DESC); - expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: CREATED_DESC } }); + expect(mutationMock).toHaveBeenCalledWith({ input: { issuesSort: UPDATED_DESC } }); }); it('captures error when mutation response has errors', async () => { @@ -952,7 +856,7 @@ describe('CE IssuesListApp component', () => { .mockResolvedValue(setSortPreferenceMutationResponseWithErrors); wrapper = mountComponent({ sortPreferenceMutationResponse: mutationMock }); - findIssuableList().vm.$emit('sort', CREATED_DESC); + findIssuableList().vm.$emit('sort', UPDATED_DESC); await waitForPromises(); expect(Sentry.captureException).toHaveBeenCalledWith(new Error('oh no!')); @@ -1016,9 +920,9 @@ describe('CE IssuesListApp component', () => { }); it('shows an alert to tell the user they must be signed in to search', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: IssuesListApp.i18n.anonymousSearchingMessage, - type: FLASH_TYPES.NOTICE, + variant: VARIANT_INFO, }); }); }); diff --git a/spec/frontend/issues/list/mock_data.js b/spec/frontend/issues/list/mock_data.js index 62fcbf7aad0..0690501dee9 100644 --- a/spec/frontend/issues/list/mock_data.js +++ b/spec/frontend/issues/list/mock_data.js @@ -1,7 +1,7 @@ import { FILTERED_SEARCH_TERM, OPERATOR_IS, - OPERATOR_IS_NOT, + OPERATOR_NOT, OPERATOR_OR, TOKEN_TYPE_ASSIGNEE, TOKEN_TYPE_AUTHOR, @@ -132,6 +132,8 @@ export const locationSearch = [ '?search=find+issues', 'author_username=homer', 'not[author_username]=marge', + 'or[author_username]=burns', + 'or[author_username]=smithers', 'assignee_username[]=bart', 'assignee_username[]=lisa', 'assignee_username[]=5', @@ -184,41 +186,43 @@ export const locationSearchWithSpecialValues = [ export const filteredTokens = [ { type: TOKEN_TYPE_AUTHOR, value: { data: 'homer', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_AUTHOR, value: { data: 'marge', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_AUTHOR, value: { data: 'burns', operator: OPERATOR_OR } }, + { type: TOKEN_TYPE_AUTHOR, value: { data: 'smithers', operator: OPERATOR_OR } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'bart', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lisa', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: '5', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_IS_NOT } }, - { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'patty', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'selma', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'carl', operator: OPERATOR_OR } }, { type: TOKEN_TYPE_ASSIGNEE, value: { data: 'lenny', operator: OPERATOR_OR } }, { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 3', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 4', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_IS_NOT } }, - { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 30', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 20', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_MILESTONE, value: { data: 'season 30', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_LABEL, value: { data: 'cartoon', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_LABEL, value: { data: 'tv', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_IS_NOT } }, - { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'live action', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_LABEL, value: { data: 'drama', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_RELEASE, value: { data: 'v3', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_RELEASE, value: { data: 'v4', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_IS_NOT } }, - { type: TOKEN_TYPE_RELEASE, value: { data: 'v30', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_RELEASE, value: { data: 'v20', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_RELEASE, value: { data: 'v30', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_TYPE, value: { data: 'issue', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_TYPE, value: { data: 'feature', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_TYPE, value: { data: 'bug', operator: OPERATOR_IS_NOT } }, - { type: TOKEN_TYPE_TYPE, value: { data: 'incident', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_TYPE, value: { data: 'bug', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_TYPE, value: { data: 'incident', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsup', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsdown', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_MY_REACTION, value: { data: 'thumbsdown', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_CONFIDENTIAL, value: { data: 'yes', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ITERATION, value: { data: '4', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ITERATION, value: { data: '12', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_ITERATION, value: { data: '20', operator: OPERATOR_IS_NOT } }, - { type: TOKEN_TYPE_ITERATION, value: { data: '42', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_ITERATION, value: { data: '20', operator: OPERATOR_NOT } }, + { type: TOKEN_TYPE_ITERATION, value: { data: '42', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_EPIC, value: { data: '12', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_EPIC, value: { data: '34', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_EPIC, value: { data: '34', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_WEIGHT, value: { data: '1', operator: OPERATOR_IS } }, - { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_IS_NOT } }, + { type: TOKEN_TYPE_WEIGHT, value: { data: '3', operator: OPERATOR_NOT } }, { type: TOKEN_TYPE_CONTACT, value: { data: '123', operator: OPERATOR_IS } }, { type: TOKEN_TYPE_ORGANIZATION, value: { data: '456', operator: OPERATOR_IS } }, { type: FILTERED_SEARCH_TERM, value: { data: 'find' } }, @@ -264,6 +268,7 @@ export const apiParams = { weight: '3', }, or: { + authorUsernames: ['burns', 'smithers'], assigneeUsernames: ['carl', 'lenny'], }, }; @@ -283,6 +288,7 @@ export const apiParamsWithSpecialValues = { export const urlParams = { author_username: 'homer', 'not[author_username]': 'marge', + 'or[author_username]': ['burns', 'smithers'], 'assignee_username[]': ['bart', 'lisa', '5'], 'not[assignee_username][]': ['patty', 'selma'], 'or[assignee_username][]': ['carl', 'lenny'], diff --git a/spec/frontend/issues/list/utils_spec.js b/spec/frontend/issues/list/utils_spec.js index 3c6332d5728..a281ed1c989 100644 --- a/spec/frontend/issues/list/utils_spec.js +++ b/spec/frontend/issues/list/utils_spec.js @@ -69,26 +69,40 @@ describe('isSortKey', () => { describe('getSortOptions', () => { describe.each` - hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsWeight | containsBlocking - ${false} | ${false} | ${10} | ${false} | ${false} - ${true} | ${false} | ${11} | ${true} | ${false} - ${false} | ${true} | ${11} | ${false} | ${true} - ${true} | ${true} | ${12} | ${true} | ${true} + hasIssuableHealthStatusFeature | hasIssueWeightsFeature | hasBlockedIssuesFeature | length | containsHealthStatus | containsWeight | containsBlocking + ${false} | ${false} | ${false} | ${10} | ${false} | ${false} | ${false} + ${false} | ${false} | ${true} | ${11} | ${false} | ${false} | ${true} + ${false} | ${true} | ${false} | ${11} | ${false} | ${true} | ${false} + ${false} | ${true} | ${true} | ${12} | ${false} | ${true} | ${true} + ${true} | ${false} | ${false} | ${11} | ${true} | ${false} | ${false} + ${true} | ${false} | ${true} | ${12} | ${true} | ${false} | ${true} + ${true} | ${true} | ${false} | ${12} | ${true} | ${true} | ${false} + ${true} | ${true} | ${true} | ${13} | ${true} | ${true} | ${true} `( - 'when hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', + 'when hasIssuableHealthStatusFeature=$hasIssuableHealthStatusFeature, hasIssueWeightsFeature=$hasIssueWeightsFeature and hasBlockedIssuesFeature=$hasBlockedIssuesFeature', ({ + hasIssuableHealthStatusFeature, hasIssueWeightsFeature, hasBlockedIssuesFeature, length, + containsHealthStatus, containsWeight, containsBlocking, }) => { - const sortOptions = getSortOptions(hasIssueWeightsFeature, hasBlockedIssuesFeature); + const sortOptions = getSortOptions({ + hasBlockedIssuesFeature, + hasIssuableHealthStatusFeature, + hasIssueWeightsFeature, + }); it('returns the correct length of sort options', () => { expect(sortOptions).toHaveLength(length); }); + it(`${containsHealthStatus ? 'contains' : 'does not contain'} health status option`, () => { + expect(sortOptions.some((option) => option.title === 'Health')).toBe(containsHealthStatus); + }); + it(`${containsWeight ? 'contains' : 'does not contain'} weight option`, () => { expect(sortOptions.some((option) => option.title === 'Weight')).toBe(containsWeight); }); diff --git a/spec/frontend/issues/related_merge_requests/store/actions_spec.js b/spec/frontend/issues/related_merge_requests/store/actions_spec.js index 4327fac15d4..d3ec6c3bc9d 100644 --- a/spec/frontend/issues/related_merge_requests/store/actions_spec.js +++ b/spec/frontend/issues/related_merge_requests/store/actions_spec.js @@ -1,6 +1,6 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as actions from '~/issues/related_merge_requests/store/actions'; import * as types from '~/issues/related_merge_requests/store/mutation_types'; @@ -95,8 +95,8 @@ describe('RelatedMergeRequest store actions', () => { [], [{ type: 'requestData' }, { type: 'receiveDataError' }], ); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: expect.stringMatching('Something went wrong'), }); }); diff --git a/spec/frontend/issues/show/components/app_spec.js b/spec/frontend/issues/show/components/app_spec.js index 3d027e2084c..6cf44e60092 100644 --- a/spec/frontend/issues/show/components/app_spec.js +++ b/spec/frontend/issues/show/components/app_spec.js @@ -5,7 +5,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import '~/behaviors/markdown/render_gfm'; +import { createAlert } from '~/flash'; import { IssuableStatus, IssuableStatusText, IssuableType } from '~/issues/constants'; import IssuableApp from '~/issues/show/components/app.vue'; import DescriptionComponent from '~/issues/show/components/description.vue'; @@ -26,8 +26,10 @@ import { zoomMeetingUrl, } from '../mock_data/mock_data'; -jest.mock('~/lib/utils/url_utility'); +jest.mock('~/flash'); jest.mock('~/issues/show/event_hub'); +jest.mock('~/lib/utils/url_utility'); +jest.mock('~/behaviors/markdown/render_gfm'); const REALTIME_REQUEST_STACK = [initialRequest, secondRequest]; @@ -270,9 +272,7 @@ describe('Issuable output', () => { await wrapper.vm.updateIssuable(); expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - `Error updating issue`, - ); + expect(createAlert).toHaveBeenCalledWith({ message: `Error updating issue` }); }); it('returns the correct error message for issuableType', async () => { @@ -282,9 +282,7 @@ describe('Issuable output', () => { await nextTick(); await wrapper.vm.updateIssuable(); expect(eventHub.$emit).not.toHaveBeenCalledWith('close.form'); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - `Error updating merge request`, - ); + expect(createAlert).toHaveBeenCalledWith({ message: `Error updating merge request` }); }); it('shows error message from backend if exists', async () => { @@ -294,9 +292,9 @@ describe('Issuable output', () => { .mockRejectedValue({ response: { data: { errors: [msg] } } }); await wrapper.vm.updateIssuable(); - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - `${wrapper.vm.defaultErrorMessage}. ${msg}`, - ); + expect(createAlert).toHaveBeenCalledWith({ + message: `${wrapper.vm.defaultErrorMessage}. ${msg}`, + }); }); }); }); @@ -354,9 +352,7 @@ describe('Issuable output', () => { .reply(() => Promise.reject(new Error('something went wrong'))); return wrapper.vm.requestTemplatesAndShowForm().then(() => { - expect(document.querySelector('.flash-container .flash-text').textContent).toContain( - 'Error updating issue', - ); + expect(createAlert).toHaveBeenCalledWith({ message: 'Error updating issue' }); expect(formSpy).toHaveBeenCalledWith(); }); @@ -402,9 +398,9 @@ describe('Issuable output', () => { wrapper.setProps({ issuableType: 'merge request' }); return wrapper.vm.updateStoreState().then(() => { - expect(document.querySelector('.flash-container .flash-text').innerText.trim()).toBe( - `Error updating ${wrapper.vm.issuableType}`, - ); + expect(createAlert).toHaveBeenCalledWith({ + message: `Error updating ${wrapper.vm.issuableType}`, + }); }); }); }); diff --git a/spec/frontend/issues/show/components/description_spec.js b/spec/frontend/issues/show/components/description_spec.js index 9d9abce887b..889ff450825 100644 --- a/spec/frontend/issues/show/components/description_spec.js +++ b/spec/frontend/issues/show/components/description_spec.js @@ -1,7 +1,6 @@ import $ from 'jquery'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import '~/behaviors/markdown/render_gfm'; import { GlTooltip, GlModal } from '@gitlab/ui'; import setWindowLocation from 'helpers/set_window_location_helper'; @@ -12,7 +11,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import Description from '~/issues/show/components/description.vue'; import { updateHistory } from '~/lib/utils/url_utility'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; @@ -21,6 +20,7 @@ import createWorkItemFromTaskMutation from '~/work_items/graphql/create_work_ite import TaskList from '~/task_list'; import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { TRACKING_CATEGORY_SHOW } from '~/work_items/constants'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { projectWorkItemTypesQueryResponse, createWorkItemFromTaskMutationResponse, @@ -37,6 +37,7 @@ jest.mock('~/lib/utils/url_utility', () => ({ updateHistory: jest.fn(), })); jest.mock('~/task_list'); +jest.mock('~/behaviors/markdown/render_gfm'); const showModal = jest.fn(); const hideModal = jest.fn(); @@ -161,7 +162,6 @@ describe('Description component', () => { }); it('applies syntax highlighting and math when description changed', async () => { - const prototypeSpy = jest.spyOn($.prototype, 'renderGFM'); createComponent(); await wrapper.setProps({ @@ -169,7 +169,7 @@ describe('Description component', () => { }); expect(findGfmContent().exists()).toBe(true); - expect(prototypeSpy).toHaveBeenCalled(); + expect(renderGFM).toHaveBeenCalled(); }); it('sets data-update-url', () => { @@ -370,7 +370,7 @@ describe('Description component', () => { await waitForPromises(); - expect(createFlash).toHaveBeenCalledWith( + expect(createAlert).toHaveBeenCalledWith( expect.objectContaining({ message: 'Something went wrong when creating task. Please try again.', }), diff --git a/spec/frontend/issues/show/components/header_actions_spec.js b/spec/frontend/issues/show/components/header_actions_spec.js index dc2b3c6fc48..7d6ca44e679 100644 --- a/spec/frontend/issues/show/components/header_actions_spec.js +++ b/spec/frontend/issues/show/components/header_actions_spec.js @@ -3,7 +3,7 @@ import { GlButton, GlDropdownItem, GlLink, GlModal } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vuex from 'vuex'; import { mockTracking } from 'helpers/tracking_helper'; -import createFlash, { FLASH_TYPES } from '~/flash'; +import { createAlert, VARIANT_SUCCESS } from '~/flash'; import { IssuableStatus, IssueType } from '~/issues/constants'; import DeleteIssueModal from '~/issues/show/components/delete_issue_modal.vue'; import HeaderActions from '~/issues/show/components/header_actions.vue'; @@ -171,19 +171,19 @@ describe('HeaderActions component', () => { ${'desktop dropdown'} | ${false} | ${findDesktopDropdownItems} | ${findDesktopDropdown} `('$description', ({ isCloseIssueItemVisible, findDropdownItems, findDropdown }) => { describe.each` - description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue - ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} - ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} - ${'when user can report abuse'} | ${'Report abuse'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} - ${'when user cannot report abuse'} | ${'Report abuse'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} - ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} - ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} + description | itemText | isItemVisible | canUpdateIssue | canCreateIssue | isIssueAuthor | canReportSpam | canPromoteToEpic | canDestroyIssue + ${`when user can update ${issueType}`} | ${`Close ${issueType}`} | ${isCloseIssueItemVisible} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot update ${issueType}`} | ${`Close ${issueType}`} | ${false} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user can create ${issueType}`} | ${`New related ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot create ${issueType}`} | ${`New related ${issueType}`} | ${false} | ${true} | ${false} | ${true} | ${true} | ${true} | ${true} + ${'when user can promote to epic'} | ${'Promote to epic'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot promote to epic'} | ${'Promote to epic'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${false} | ${true} + ${'when user can report abuse'} | ${'Report abuse to administrator'} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} | ${true} + ${'when user cannot report abuse'} | ${'Report abuse to administrator'} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user can submit as spam'} | ${'Submit as spam'} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${'when user cannot submit as spam'} | ${'Submit as spam'} | ${false} | ${true} | ${true} | ${true} | ${false} | ${true} | ${true} + ${`when user can delete ${issueType}`} | ${`Delete ${issueType}`} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} | ${true} + ${`when user cannot delete ${issueType}`} | ${`Delete ${issueType}`} | ${false} | ${true} | ${true} | ${true} | ${true} | ${true} | ${false} `( '$description', ({ @@ -284,9 +284,9 @@ describe('HeaderActions component', () => { }); it('shows a success message and tells the user they are being redirected', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'The issue was successfully promoted to an epic. Redirecting to epic...', - type: FLASH_TYPES.SUCCESS, + variant: VARIANT_SUCCESS, }); }); @@ -309,7 +309,7 @@ describe('HeaderActions component', () => { }); it('shows an error message', () => { - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: HeaderActions.i18n.promoteErrorMessage, }); }); diff --git a/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js b/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js index 4c1638a9147..81c3c30bf8a 100644 --- a/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js +++ b/spec/frontend/issues/show/components/incidents/edit_timeline_event_spec.js @@ -40,5 +40,13 @@ describe('Edit Timeline events', () => { expect(wrapper.emitted()).toEqual(cancelEvent); }); + + it('should emit the delete event', async () => { + const deleteEvent = { delete: [[]] }; + + await findTimelineEventsForm().vm.$emit('delete'); + + expect(wrapper.emitted()).toEqual(deleteEvent); + }); }); }); diff --git a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js index 458c1c3f858..33a3a6eddfc 100644 --- a/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js +++ b/spec/frontend/issues/show/components/incidents/incident_tabs_spec.js @@ -1,10 +1,11 @@ -import { GlTab } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; import merge from 'lodash/merge'; +import { mountExtended, shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { trackIncidentDetailsViewsOptions } from '~/incidents/constants'; import DescriptionComponent from '~/issues/show/components/description.vue'; import HighlightBar from '~/issues/show/components/incidents/highlight_bar.vue'; -import IncidentTabs from '~/issues/show/components/incidents/incident_tabs.vue'; +import IncidentTabs, { + incidentTabsI18n, +} from '~/issues/show/components/incidents/incident_tabs.vue'; import INVALID_URL from '~/lib/utils/invalid_url'; import Tracking from '~/tracking'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; @@ -16,11 +17,24 @@ const mockAlert = { iid: '1', }; +const defaultMocks = { + $apollo: { + queries: { + alert: { + loading: true, + }, + timelineEvents: { + loading: false, + }, + }, + }, +}; + describe('Incident Tabs component', () => { let wrapper; - const mountComponent = (data = {}, options = {}) => { - wrapper = shallowMount( + const mountComponent = ({ data = {}, options = {}, mount = shallowMountExtended } = {}) => { + wrapper = mount( IncidentTabs, merge( { @@ -29,7 +43,7 @@ describe('Incident Tabs component', () => { }, stubs: { DescriptionComponent: true, - MetricsTab: true, + IncidentMetricTab: true, }, provide: { fullPath: '', @@ -37,41 +51,37 @@ describe('Incident Tabs component', () => { projectId: '', issuableId: '', uploadMetricsFeatureAvailable: true, + slaFeatureAvailable: true, + canUpdate: true, + canUpdateTimelineEvent: true, }, data() { return { alert: mockAlert, ...data }; }, - mocks: { - $apollo: { - queries: { - alert: { - loading: true, - }, - timelineEvents: { - loading: false, - }, - }, - }, - }, + mocks: defaultMocks, }, options, ), ); }; - const findTabs = () => wrapper.findAllComponents(GlTab); - const findSummaryTab = () => findTabs().at(0); - const findAlertDetailsTab = () => wrapper.find('[data-testid="alert-details-tab"]'); + const findSummaryTab = () => wrapper.findByTestId('summary-tab'); + const findTimelineTab = () => wrapper.findByTestId('timeline-tab'); + const findAlertDetailsTab = () => wrapper.findByTestId('alert-details-tab'); const findAlertDetailsComponent = () => wrapper.findComponent(AlertDetailsTable); const findDescriptionComponent = () => wrapper.findComponent(DescriptionComponent); const findHighlightBarComponent = () => wrapper.findComponent(HighlightBar); + const findTabButtonByFilter = (filter) => wrapper.findAllByRole('tab').filter(filter); + const findTimelineTabButton = () => + findTabButtonByFilter((inner) => inner.text() === incidentTabsI18n.timelineTitle).at(0); + const findActiveTabs = () => findTabButtonByFilter((inner) => inner.classes('active')); - describe('empty state', () => { + describe('with no alerts', () => { beforeEach(() => { - mountComponent({ alert: null }); + mountComponent({ data: { alert: null } }); }); - it('does not show the alert details tab', () => { + it('does not show the alert details tab option', () => { expect(findAlertDetailsComponent().exists()).toBe(false); }); }); @@ -83,7 +93,12 @@ describe('Incident Tabs component', () => { it('renders the summary tab', () => { expect(findSummaryTab().exists()).toBe(true); - expect(findSummaryTab().attributes('title')).toBe('Summary'); + expect(findSummaryTab().attributes('title')).toBe(incidentTabsI18n.summaryTitle); + }); + + it('renders the timeline tab', () => { + expect(findTimelineTab().exists()).toBe(true); + expect(findTimelineTab().attributes('title')).toBe(incidentTabsI18n.timelineTitle); }); it('renders the alert details tab', () => { @@ -125,4 +140,22 @@ describe('Incident Tabs component', () => { expect(Tracking.event).toHaveBeenCalledWith(category, action); }); }); + + describe('tab changing', () => { + beforeEach(() => { + mountComponent({ mount: mountExtended }); + }); + + it('shows only the summary tab by default', async () => { + expect(findActiveTabs()).toHaveLength(1); + expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.summaryTitle); + }); + + it("shows the timeline tab after it's clicked", async () => { + await findTimelineTabButton().trigger('click'); + + expect(findActiveTabs()).toHaveLength(1); + expect(findActiveTabs().at(0).text()).toBe(incidentTabsI18n.timelineTitle); + }); + }); }); diff --git a/spec/frontend/issues/show/components/incidents/mock_data.js b/spec/frontend/issues/show/components/incidents/mock_data.js index adea2b6df59..9accfcea791 100644 --- a/spec/frontend/issues/show/components/incidents/mock_data.js +++ b/spec/frontend/issues/show/components/incidents/mock_data.js @@ -13,6 +13,9 @@ export const mockEvents = [ noteHtml: '<p>Dummy event 1</p>', occurredAt: '2022-03-22T15:59:00Z', updatedAt: '2022-03-22T15:59:08Z', + timelineEventTags: { + nodes: [], + }, __typename: 'TimelineEventType', }, { @@ -29,6 +32,18 @@ export const mockEvents = [ noteHtml: '<p>Dummy event 2</p>', occurredAt: '2022-03-23T14:57:00Z', updatedAt: '2022-03-23T14:57:08Z', + timelineEventTags: { + nodes: [ + { + id: 'gid://gitlab/IncidentManagement::TimelineEvent/132', + name: 'Start time', + }, + { + id: 'gid://gitlab/IncidentManagement::TimelineEvent/132', + name: 'End time', + }, + ], + }, __typename: 'TimelineEventType', }, { @@ -45,6 +60,9 @@ export const mockEvents = [ noteHtml: '<p>Dummy event 3</p>', occurredAt: '2022-03-23T15:59:00Z', updatedAt: '2022-03-23T15:59:08Z', + timelineEventTags: { + nodes: [], + }, __typename: 'TimelineEventType', }, ]; @@ -152,6 +170,9 @@ export const mockGetTimelineData = { action: 'comment', occurredAt: '2022-07-01T12:47:00Z', createdAt: '2022-07-20T12:47:40Z', + timelineEventTags: { + nodes: [], + }, }, ], }, diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js index 0ce3f75f576..d5b199cc790 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_form_spec.js @@ -22,11 +22,12 @@ describe('Timeline events form', () => { useFakeDate(fakeDate); let wrapper; - const mountComponent = ({ mountMethod = shallowMountExtended } = {}) => { + const mountComponent = ({ mountMethod = shallowMountExtended } = {}, props = {}) => { wrapper = mountMethod(TimelineEventsForm, { propsData: { showSaveAndAdd: true, isEventProcessed: false, + ...props, }, stubs: { GlButton: true, @@ -43,6 +44,7 @@ describe('Timeline events form', () => { const findSubmitButton = () => wrapper.findByText(timelineFormI18n.save); const findSubmitAndAddButton = () => wrapper.findByText(timelineFormI18n.saveAndAdd); const findCancelButton = () => wrapper.findByText(timelineFormI18n.cancel); + const findDeleteButton = () => wrapper.findByText(timelineFormI18n.delete); const findDatePicker = () => wrapper.findComponent(GlDatepicker); const findHourInput = () => wrapper.findByTestId('input-hours'); const findMinuteInput = () => wrapper.findByTestId('input-minutes'); @@ -68,6 +70,9 @@ describe('Timeline events form', () => { findCancelButton().vm.$emit('click'); await waitForPromises(); }; + const deleteForm = () => { + findDeleteButton().vm.$emit('click'); + }; it('renders markdown-field component with correct list of toolbar items', () => { mountComponent({ mountMethod: mountExtended }); @@ -165,4 +170,38 @@ describe('Timeline events form', () => { expect(findSubmitAndAddButton().props('disabled')).toBe(true); }); }); + + describe('Delete button', () => { + it('does not show the delete button if showDelete prop is false', () => { + mountComponent({ mountMethod: mountExtended }, { showDelete: false }); + + expect(findDeleteButton().exists()).toBe(false); + }); + + it('shows the delete button if showDelete prop is true', () => { + mountComponent({ mountMethod: mountExtended }, { showDelete: true }); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('disables the delete button if isEventProcessed prop is true', () => { + mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true }); + + expect(findDeleteButton().props('disabled')).toBe(true); + }); + + it('does not disable the delete button if isEventProcessed prop is false', () => { + mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: false }); + + expect(findDeleteButton().props('disabled')).toBe(false); + }); + + it('emits delete event on click', () => { + mountComponent({ mountMethod: mountExtended }, { showDelete: true, isEventProcessed: true }); + + deleteForm(); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); }); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js index 1bf8d68efd4..ba0527e5395 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_item_spec.js @@ -1,5 +1,5 @@ import timezoneMock from 'timezone-mock'; -import { GlIcon, GlDropdown } from '@gitlab/ui'; +import { GlIcon, GlDropdown, GlBadge } from '@gitlab/ui'; import { nextTick } from 'vue'; import { timelineItemI18n } from '~/issues/show/components/incidents/constants'; import { mountExtended } from 'helpers/vue_test_utils_helper'; @@ -27,25 +27,24 @@ describe('IncidentTimelineEventList', () => { const findCommentIcon = () => wrapper.findComponent(GlIcon); const findEventTime = () => wrapper.findByTestId('event-time'); + const findEventTag = () => wrapper.findComponent(GlBadge); const findDropdown = () => wrapper.findComponent(GlDropdown); const findDeleteButton = () => wrapper.findByText(timelineItemI18n.delete); describe('template', () => { - it('shows comment icon', () => { + beforeEach(() => { mountComponent(); + }); + it('shows comment icon', () => { expect(findCommentIcon().exists()).toBe(true); }); it('sets correct props for icon', () => { - mountComponent(); - expect(findCommentIcon().props('name')).toBe(mockEvents[0].action); }); it('displays the correct time', () => { - mountComponent(); - expect(findEventTime().text()).toBe('15:59 UTC'); }); @@ -58,8 +57,6 @@ describe('IncidentTimelineEventList', () => { describe(timezone, () => { beforeEach(() => { timezoneMock.register(timezone); - - mountComponent(); }); afterEach(() => { @@ -72,10 +69,20 @@ describe('IncidentTimelineEventList', () => { }); }); + describe('timeline event tag', () => { + it('does not show when tag is not provided', () => { + expect(findEventTag().exists()).toBe(false); + }); + + it('shows when tag is provided', () => { + mountComponent({ propsData: { eventTag: 'Start time' } }); + + expect(findEventTag().exists()).toBe(true); + }); + }); + describe('action dropdown', () => { it('does not show the action dropdown by default', () => { - mountComponent(); - expect(findDropdown().exists()).toBe(false); expect(findDeleteButton().exists()).toBe(false); }); diff --git a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js index dff1c429d07..a7250e8ad0d 100644 --- a/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js +++ b/spec/frontend/issues/show/components/incidents/timeline_events_list_spec.js @@ -92,6 +92,9 @@ describe('IncidentTimelineEventList', () => { expect(findItems().at(1).props('occurredAt')).toBe(mockEvents[1].occurredAt); expect(findItems().at(1).props('action')).toBe(mockEvents[1].action); expect(findItems().at(1).props('noteHtml')).toBe(mockEvents[1].noteHtml); + expect(findItems().at(1).props('eventTag')).toBe( + mockEvents[1].timelineEventTags.nodes[0].name, + ); }); it('formats dates correctly', () => { @@ -120,6 +123,20 @@ describe('IncidentTimelineEventList', () => { }); }); + describe('getFirstTag', () => { + it('returns undefined, when timelineEventTags contains an empty array', () => { + const returnedTag = wrapper.vm.getFirstTag(mockEvents[0].timelineEventTags); + + expect(returnedTag).toEqual(undefined); + }); + + it('returns the first string, when timelineEventTags contains array with at least one tag', () => { + const returnedTag = wrapper.vm.getFirstTag(mockEvents[1].timelineEventTags); + + expect(returnedTag).toBe(mockEvents[1].timelineEventTags.nodes[0].name); + }); + }); + describe('delete functionality', () => { beforeEach(() => { mockConfirmAction({ confirmed: true }); diff --git a/spec/frontend/issues/show/components/locked_warning_spec.js b/spec/frontend/issues/show/components/locked_warning_spec.js new file mode 100644 index 00000000000..08f0338d41b --- /dev/null +++ b/spec/frontend/issues/show/components/locked_warning_spec.js @@ -0,0 +1,55 @@ +import { GlAlert, GlLink } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { sprintf } from '~/locale'; +import { IssuableType } from '~/issues/constants'; +import LockedWarning, { i18n } from '~/issues/show/components/locked_warning.vue'; + +describe('LockedWarning component', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mountExtended(LockedWarning, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findAlert = () => wrapper.findComponent(GlAlert); + const findLink = () => wrapper.findComponent(GlLink); + + describe.each([IssuableType.Issue, IssuableType.Epic])( + 'with issuableType set to %s', + (issuableType) => { + let alert; + let link; + beforeEach(() => { + createComponent({ issuableType }); + alert = findAlert(); + link = findLink(); + }); + + afterEach(() => { + alert = null; + link = null; + }); + + it('displays a non-closable alert', () => { + expect(alert.exists()).toBe(true); + expect(alert.props('dismissible')).toBe(false); + }); + + it(`displays correct message`, async () => { + expect(alert.text()).toMatchInterpolatedText(sprintf(i18n.alertMessage, { issuableType })); + }); + + it(`displays a link with correct text`, async () => { + expect(link.exists()).toBe(true); + expect(link.text()).toBe(`the ${issuableType}`); + }); + }, + ); +}); diff --git a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js index 56eb6d75def..56e425fa4eb 100644 --- a/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js +++ b/spec/frontend/jira_connect/branches/components/source_branch_dropdown_spec.js @@ -1,4 +1,4 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon, GlSearchBoxByType } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; @@ -29,19 +29,12 @@ const mockQueryLoading = jest.fn().mockReturnValue(new Promise(() => {})); describe('SourceBranchDropdown', () => { let wrapper; - const findDropdown = () => wrapper.findComponent(GlDropdown); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findDropdownItemByText = (text) => - findAllDropdownItems().wrappers.find((item) => item.text() === text); - const findSearchBox = () => wrapper.findComponent(GlSearchBoxByType); - - const assertDropdownItems = () => { - const dropdownItems = findAllDropdownItems(); - expect(dropdownItems.wrappers).toHaveLength(mockProject.repository.branchNames.length); - expect(dropdownItems.wrappers.map((item) => item.text())).toEqual( - mockProject.repository.branchNames, - ); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); + + const assertListboxItems = () => { + const listboxItems = findListbox().props('items'); + expect(listboxItems).toHaveLength(mockProject.repository.branchNames.length); + expect(listboxItems.map((item) => item.text)).toEqual(mockProject.repository.branchNames); }; function createMockApolloProvider({ getProjectQueryLoading = false } = {}) { @@ -70,8 +63,8 @@ describe('SourceBranchDropdown', () => { createComponent(); }); - it('sets dropdown `disabled` prop to `true`', () => { - expect(findDropdown().props('disabled')).toBe(true); + it('sets listbox `disabled` prop to `true`', () => { + expect(findListbox().props('disabled')).toBe(true); }); describe('when `selectedProject` becomes specified', () => { @@ -83,29 +76,30 @@ describe('SourceBranchDropdown', () => { await waitForPromises(); }); - it('sets dropdown props correctly', () => { - expect(findDropdown().props()).toMatchObject({ - loading: false, + it('sets listbox props correctly', () => { + expect(findListbox().props()).toMatchObject({ disabled: false, - text: 'Select a branch', + loading: false, + searchable: true, + searching: false, + toggleText: 'Select a branch', }); }); - it('renders available source branches as dropdown items', () => { - assertDropdownItems(); + it('renders available source branches as listbox items', () => { + assertListboxItems(); }); }); }); describe('when `selectedProject` prop is specified', () => { describe('when branches are loading', () => { - it('renders loading icon in dropdown', () => { + it('sets loading prop to true', () => { createComponent({ mockApollo: createMockApolloProvider({ getProjectQueryLoading: true }), props: { selectedProject: mockSelectedProject }, }); - - expect(findLoadingIcon().isVisible()).toBe(true); + expect(findListbox().props('loading')).toEqual(true); }); }); @@ -117,7 +111,7 @@ describe('SourceBranchDropdown', () => { jest.clearAllMocks(); const mockSearchTerm = 'mai'; - await findSearchBox().vm.$emit('input', mockSearchTerm); + await findListbox().vm.$emit('search', mockSearchTerm); expect(mockGetProjectQuery).toHaveBeenCalledWith({ branchNamesLimit: BRANCHES_PER_PAGE, @@ -134,32 +128,32 @@ describe('SourceBranchDropdown', () => { await waitForPromises(); }); - it('sets dropdown props correctly', () => { - expect(findDropdown().props()).toMatchObject({ - loading: false, + it('sets listbox props correctly', () => { + expect(findListbox().props()).toMatchObject({ disabled: false, - text: 'Select a branch', + loading: false, + searchable: true, + searching: false, + toggleText: 'Select a branch', }); }); - it('omits monospace styling from dropdown', () => { - expect(findDropdown().classes()).not.toContain('gl-font-monospace'); + it('omits monospace styling from listbox', () => { + expect(findListbox().classes()).not.toContain('gl-font-monospace'); }); - it('renders available source branches as dropdown items', () => { - assertDropdownItems(); + it('renders available source branches as listbox items', () => { + assertListboxItems(); }); it("emits `change` event with the repository's `rootRef` by default", () => { expect(wrapper.emitted('change')[0]).toEqual([mockProject.repository.rootRef]); }); - describe('when selecting a dropdown item', () => { + describe('when selecting a listbox item', () => { it('emits `change` event with the selected branch name', async () => { const mockBranchName = mockProject.repository.branchNames[1]; - const itemToSelect = findDropdownItemByText(mockBranchName); - await itemToSelect.vm.$emit('click'); - + findListbox().vm.$emit('select', mockBranchName); expect(wrapper.emitted('change')[1]).toEqual([mockBranchName]); }); }); @@ -173,16 +167,12 @@ describe('SourceBranchDropdown', () => { }); }); - it('sets `isChecked` prop of the corresponding dropdown item to `true`', () => { - expect(findDropdownItemByText(mockBranchName).props('isChecked')).toBe(true); - }); - - it('sets dropdown text to `selectedBranchName` value', () => { - expect(findDropdown().props('text')).toBe(mockBranchName); + it('sets listbox text to `selectedBranchName` value', () => { + expect(findListbox().props('toggleText')).toBe(mockBranchName); }); - it('adds monospace styling to dropdown', () => { - expect(findDropdown().classes()).toContain('gl-font-monospace'); + it('adds monospace styling to listbox', () => { + expect(findListbox().classes()).toContain('gl-font-monospace'); }); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js index 10696d25f17..e98c6ff1054 100644 --- a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index_spec.js @@ -1,12 +1,14 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue'; import SignInGitlabMultiversion from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/index.vue'; -import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue'; import SignInOauthButton from '~/jira_connect/subscriptions/components/sign_in_oauth_button.vue'; +import VersionSelectForm from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/version_select_form.vue'; import { updateInstallation } from '~/jira_connect/subscriptions/api'; import { reloadPage, persistBaseUrl, retrieveBaseUrl } from '~/jira_connect/subscriptions/utils'; +import { GITLAB_COM_BASE_PATH } from '~/jira_connect/subscriptions/constants'; jest.mock('~/jira_connect/subscriptions/api', () => { return { @@ -21,8 +23,9 @@ describe('SignInGitlabMultiversion', () => { const mockBasePath = 'gitlab.mycompany.com'; - const findVersionSelectForm = () => wrapper.findComponent(VersionSelectForm); + const findSetupInstructions = () => wrapper.findComponent(SetupInstructions); const findSignInOauthButton = () => wrapper.findComponent(SignInOauthButton); + const findVersionSelectForm = () => wrapper.findComponent(VersionSelectForm); const findSubtitle = () => wrapper.findByTestId('subtitle'); const createComponent = () => { @@ -59,15 +62,48 @@ describe('SignInGitlabMultiversion', () => { }); describe('when version is selected', () => { - beforeEach(() => { - retrieveBaseUrl.mockReturnValue(mockBasePath); - createComponent(); + describe('when on self-managed', () => { + beforeEach(() => { + retrieveBaseUrl.mockReturnValue(mockBasePath); + createComponent(); + }); + + it('renders correct subtitle', () => { + expect(findSubtitle().text()).toBe(SignInGitlabMultiversion.i18n.signInSubtitle); + }); + + it('renders setup instructions', () => { + expect(findSetupInstructions().exists()).toBe(true); + }); + + describe('when SetupInstructions emits `next` event', () => { + beforeEach(async () => { + findSetupInstructions().vm.$emit('next'); + await nextTick(); + }); + + it('renders sign in button', () => { + expect(findSignInOauthButton().props('gitlabBasePath')).toBe(mockBasePath); + }); + + it('hides setup instructions', () => { + expect(findSetupInstructions().exists()).toBe(false); + }); + }); }); - describe('sign in button', () => { + describe('when on GitLab.com', () => { + beforeEach(() => { + retrieveBaseUrl.mockReturnValue(GITLAB_COM_BASE_PATH); + createComponent(); + }); + + it('does not render setup instructions', () => { + expect(findSetupInstructions().exists()).toBe(false); + }); + it('renders sign in button', () => { - expect(findSignInOauthButton().exists()).toBe(true); - expect(findSignInOauthButton().props('gitlabBasePath')).toBe(mockBasePath); + expect(findSignInOauthButton().props('gitlabBasePath')).toBe(GITLAB_COM_BASE_PATH); }); describe('when button emits `sign-in` event', () => { @@ -90,9 +126,5 @@ describe('SignInGitlabMultiversion', () => { }); }); }); - - it('renders correct subtitle', () => { - expect(findSubtitle().text()).toBe(SignInGitlabMultiversion.i18n.signInSubtitle); - }); }); }); diff --git a/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js new file mode 100644 index 00000000000..5496cf008c5 --- /dev/null +++ b/spec/frontend/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions_spec.js @@ -0,0 +1,35 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton, GlLink } from '@gitlab/ui'; + +import { OAUTH_SELF_MANAGED_DOC_LINK } from '~/jira_connect/subscriptions/constants'; +import SetupInstructions from '~/jira_connect/subscriptions/pages/sign_in/sign_in_gitlab_multiversion/setup_instructions.vue'; + +describe('SetupInstructions', () => { + let wrapper; + + const findGlButton = () => wrapper.findComponent(GlButton); + const findGlLink = () => wrapper.findComponent(GlLink); + + const createComponent = () => { + wrapper = shallowMount(SetupInstructions); + }; + + describe('template', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders "Learn more" link to documentation', () => { + expect(findGlLink().attributes('href')).toBe(OAUTH_SELF_MANAGED_DOC_LINK); + }); + + describe('when button is clicked', () => { + it('emits "next" event', () => { + expect(wrapper.emitted('next')).toBeUndefined(); + findGlButton().vm.$emit('click'); + + expect(wrapper.emitted('next')).toHaveLength(1); + }); + }); + }); +}); diff --git a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap index a72528ae36b..748e151f31b 100644 --- a/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap +++ b/spec/frontend/jira_import/components/__snapshots__/jira_import_form_spec.js.snap @@ -87,7 +87,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <div aria-label="The GitLab user to which the Jira user Jane Doe will be mapped" - class="dropdown b-dropdown gl-new-dropdown w-100 btn-group" + class="dropdown b-dropdown gl-dropdown w-100 btn-group" > <!----> <button @@ -101,7 +101,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <span - class="gl-new-dropdown-button-text" + class="gl-dropdown-button-text" > janedoe </span> @@ -123,14 +123,14 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = tabindex="-1" > <div - class="gl-new-dropdown-inner" + class="gl-dropdown-inner" > <!----> <!----> <div - class="gl-new-dropdown-contents" + class="gl-dropdown-contents" > <!----> @@ -165,7 +165,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = </div> <li - class="gl-new-dropdown-text text-secondary" + class="gl-dropdown-text text-secondary" role="presentation" > <p @@ -218,7 +218,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = > <div aria-label="The GitLab user to which the Jira user Fred Chopin will be mapped" - class="dropdown b-dropdown gl-new-dropdown w-100 btn-group" + class="dropdown b-dropdown gl-dropdown w-100 btn-group" > <!----> <button @@ -232,7 +232,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = <!----> <span - class="gl-new-dropdown-button-text" + class="gl-dropdown-button-text" > mrgitlab </span> @@ -254,14 +254,14 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = tabindex="-1" > <div - class="gl-new-dropdown-inner" + class="gl-dropdown-inner" > <!----> <!----> <div - class="gl-new-dropdown-contents" + class="gl-dropdown-contents" > <!----> @@ -296,7 +296,7 @@ exports[`JiraImportForm table body shows correct information in each cell 1`] = </div> <li - class="gl-new-dropdown-text text-secondary" + class="gl-dropdown-text text-secondary" role="presentation" > <p diff --git a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js index 98bdfc3fcbc..14613775791 100644 --- a/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js +++ b/spec/frontend/jobs/components/filtered_search/jobs_filtered_search_spec.js @@ -1,6 +1,10 @@ import { GlFilteredSearch } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + OPERATORS_IS, + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; import JobsFilteredSearch from '~/jobs/components/filtered_search/jobs_filtered_search.vue'; import { mockFailedSearchToken } from '../../mock_data'; @@ -37,11 +41,11 @@ describe('Jobs filtered search', () => { createComponent(); expect(findStatusToken()).toMatchObject({ - type: 'status', + type: TOKEN_TYPE_STATUS, icon: 'status', - title: 'Status', + title: TOKEN_TITLE_STATUS, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }); }); @@ -65,7 +69,7 @@ describe('Jobs filtered search', () => { createComponent({ queryString: { statuses: value } }); expect(findFilteredSearch().props('value')).toEqual([ - { type: 'status', value: { data: value, operator: '=' } }, + { type: TOKEN_TYPE_STATUS, value: { data: value, operator: '=' } }, ]); }); }); diff --git a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js index 92ce3925a90..fbe5f6a2e11 100644 --- a/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js +++ b/spec/frontend/jobs/components/filtered_search/tokens/job_status_token_spec.js @@ -2,6 +2,10 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitl import { shallowMount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import JobStatusToken from '~/jobs/components/filtered_search/tokens/job_status_token.vue'; +import { + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; describe('Job Status Token', () => { let wrapper; @@ -13,9 +17,9 @@ describe('Job Status Token', () => { const defaultProps = { config: { - type: 'status', + type: TOKEN_TYPE_STATUS, icon: 'status', - title: 'Status', + title: TOKEN_TITLE_STATUS, unique: true, }, value: { diff --git a/spec/frontend/jobs/components/job/empty_state_spec.js b/spec/frontend/jobs/components/job/empty_state_spec.js index 299b607ad78..c6ab259bf46 100644 --- a/spec/frontend/jobs/components/job/empty_state_spec.js +++ b/spec/frontend/jobs/components/job/empty_state_spec.js @@ -1,5 +1,7 @@ -import { mount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import EmptyState from '~/jobs/components/job/empty_state.vue'; +import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; +import { mockFullPath, mockId } from './mock_data'; describe('Empty State', () => { let wrapper; @@ -7,26 +9,31 @@ describe('Empty State', () => { const defaultProps = { illustrationPath: 'illustrations/pending_job_empty.svg', illustrationSizeClass: 'svg-430', + jobId: mockId, title: 'This job has not started yet', playable: false, + isRetryable: true, }; const createWrapper = (props) => { - wrapper = mount(EmptyState, { + wrapper = shallowMountExtended(EmptyState, { propsData: { ...defaultProps, ...props, }, + provide: { + projectPath: mockFullPath, + }, }); }; 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"]'); + const findTitle = () => wrapper.findByTestId('job-empty-state-title'); + const findContent = () => wrapper.findByTestId('job-empty-state-content'); + const findAction = () => wrapper.findByTestId('job-empty-state-action'); + const findManualVarsForm = () => wrapper.findComponent(ManualVariablesForm); afterEach(() => { if (wrapper?.destroy) { diff --git a/spec/frontend/jobs/components/job/job_app_spec.js b/spec/frontend/jobs/components/job/job_app_spec.js index 822528403cf..98f1979db1b 100644 --- a/spec/frontend/jobs/components/job/job_app_spec.js +++ b/spec/frontend/jobs/components/job/job_app_spec.js @@ -1,14 +1,15 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; -import MockAdapter from 'axios-mock-adapter'; import Vuex from 'vuex'; -import delayedJobFixture from 'test_fixtures/jobs/delayed.json'; +import { GlLoadingIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { TEST_HOST } from 'helpers/test_constants'; import EmptyState from '~/jobs/components/job/empty_state.vue'; import EnvironmentsBlock from '~/jobs/components/job/environments_block.vue'; import ErasedBlock from '~/jobs/components/job/erased_block.vue'; import JobApp from '~/jobs/components/job/job_app.vue'; +import JobLog from '~/jobs/components/log/log.vue'; +import JobLogTopBar from '~/jobs/components/job/job_log_controllers.vue'; import Sidebar from '~/jobs/components/job/sidebar/sidebar.vue'; import StuckBlock from '~/jobs/components/job/stuck_block.vue'; import UnmetPrerequisitesBlock from '~/jobs/components/job/unmet_prerequisites_block.vue'; @@ -40,7 +41,10 @@ describe('Job App', () => { }; const createComponent = () => { - wrapper = mount(JobApp, { propsData: { ...props }, store }); + wrapper = shallowMountExtended(JobApp, { + propsData: { ...props }, + store, + }); }; const setupAndMount = async ({ jobData = {}, jobLogData = {} } = {}) => { @@ -59,22 +63,16 @@ describe('Job App', () => { const findLoadingComponent = () => wrapper.findComponent(GlLoadingIcon); const findSidebar = () => wrapper.findComponent(Sidebar); - const findJobContent = () => wrapper.find('[data-testid="job-content"'); const findStuckBlockComponent = () => wrapper.findComponent(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.findComponent(UnmetPrerequisitesBlock); const findEnvironmentsBlockComponent = () => wrapper.findComponent(EnvironmentsBlock); const findErasedBlock = () => wrapper.findComponent(ErasedBlock); - const findArchivedJob = () => wrapper.find('[data-testid="archived-job"]'); const findEmptyState = () => wrapper.findComponent(EmptyState); - const findJobNewIssueLink = () => wrapper.find('[data-testid="job-new-issue"]'); - const findJobEmptyStateTitle = () => wrapper.find('[data-testid="job-empty-state-title"]'); - const findJobLogScrollTop = () => wrapper.find('[data-testid="job-controller-scroll-top"]'); - const findJobLogScrollBottom = () => wrapper.find('[data-testid="job-controller-scroll-bottom"]'); - const findJobLogController = () => wrapper.find('[data-testid="job-raw-link-controller"]'); - const findJobLogEraseLink = () => wrapper.find('[data-testid="job-log-erase-link"]'); + const findJobLog = () => wrapper.findComponent(JobLog); + const findJobLogTopBar = () => wrapper.findComponent(JobLogTopBar); + + const findJobContent = () => wrapper.findByTestId('job-content'); + const findArchivedJob = () => wrapper.findByTestId('archived-job'); beforeEach(() => { mock = new MockAdapter(axios); @@ -116,36 +114,6 @@ describe('Job App', () => { expect(wrapper.vm.shouldRenderCalloutMessage).toBe(true); })); }); - - describe('triggered job', () => { - beforeEach(() => { - const aYearAgo = new Date(); - aYearAgo.setFullYear(aYearAgo.getFullYear() - 1); - - return setupAndMount({ - jobData: { started: aYearAgo.toISOString(), started_at: aYearAgo.toISOString() }, - }); - }); - - it('should render provided job information', () => { - expect(wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim()).toContain( - 'passed Job test triggered 1 year ago by Root', - ); - }); - - it('should render new issue link', () => { - expect(findJobNewIssueLink().attributes('href')).toEqual(job.new_issue_path); - }); - }); - - describe('created job', () => { - it('should render created key', () => - setupAndMount().then(() => { - expect( - wrapper.find('.header-main-content').text().replace(/\s+/g, ' ').trim(), - ).toContain('passed Job test created 3 weeks ago by Root'); - })); - }); }); describe('stuck block', () => { @@ -169,57 +137,10 @@ describe('Job App', () => { }, }).then(() => { expect(findStuckBlockComponent().exists()).toBe(true); - expect(findStuckBlockNoActiveRunners().exists()).toBe(true); - })); - }); - - describe('when available runners can not run specified tag', () => { - it('renders tags in stuck block when there are no runners', () => - setupAndMount({ - jobData: { - status: { - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - details_path: 'path', - }, - stuck: true, - runners: { - available: false, - online: false, - }, - }, - }).then(() => { - expect(findStuckBlockComponent().text()).toContain(job.tags[0]); - expect(findStuckBlockWithTags().exists()).toBe(true); - })); - }); - - describe('when runners are offline and build has tags', () => { - it('renders message about job being stuck because of no runners with the specified tags', () => - setupAndMount({ - jobData: { - status: { - group: 'pending', - icon: 'status_pending', - label: 'pending', - text: 'pending', - details_path: 'path', - }, - stuck: true, - runners: { - available: true, - online: true, - }, - }, - }).then(() => { - expect(findStuckBlockComponent().text()).toContain(job.tags[0]); - expect(findStuckBlockWithTags().exists()).toBe(true); })); }); - it('does not renders stuck block when there are no runners', () => + it('does not render stuck block when there are runners', () => setupAndMount({ jobData: { runners: { available: true }, @@ -351,45 +272,13 @@ describe('Job App', () => { setupAndMount({ jobData: { has_trace: true } }).then(() => { expect(findEmptyState().exists()).toBe(false); })); - - it('displays remaining time for a delayed job', () => { - const oneHourInMilliseconds = 3600000; - jest - .spyOn(Date, 'now') - .mockImplementation( - () => new Date(delayedJobFixture.scheduled_at).getTime() - oneHourInMilliseconds, - ); - return setupAndMount({ jobData: delayedJobFixture }).then(() => { - expect(findEmptyState().exists()).toBe(true); - - const title = findJobEmptyStateTitle().text(); - - expect(title).toEqual('This is a delayed job to run in 01:00:00'); - }); - }); }); describe('sidebar', () => { - it('has no blank blocks', async () => { - await setupAndMount({ - jobData: { - duration: null, - finished_at: null, - erased_at: null, - queued: null, - runner: null, - coverage: null, - tags: [], - cancel_path: null, - }, - }); + it('renders sidebar', async () => { + await setupAndMount(); - const blocks = wrapper.findAll('.blocks-container > *').wrappers; - expect(blocks.length).toBeGreaterThan(0); - - blocks.forEach((block) => { - expect(block.text().trim()).not.toBe(''); - }); + expect(findSidebar().exists()).toBe(true); }); }); }); @@ -410,31 +299,15 @@ describe('Job App', () => { }); }); - describe('job log controls', () => { - beforeEach(() => - setupAndMount({ - jobLogData: { - html: '<span>Update</span>', - status: 'success', - append: false, - size: 50, - total: 100, - complete: true, - }, - }), - ); - - it('should render scroll buttons', () => { - expect(findJobLogScrollTop().exists()).toBe(true); - expect(findJobLogScrollBottom().exists()).toBe(true); - }); + describe('job log', () => { + beforeEach(() => setupAndMount()); - it('should render link to raw ouput', () => { - expect(findJobLogController().exists()).toBe(true); + it('should render job log header', () => { + expect(findJobLogTopBar().exists()).toBe(true); }); - it('should render link to erase job', () => { - expect(findJobLogEraseLink().exists()).toBe(true); + it('should render job log', () => { + expect(findJobLog().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js index 18d5f35bde4..91821a38a78 100644 --- a/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js +++ b/spec/frontend/jobs/components/job/job_sidebar_retry_button_spec.js @@ -16,6 +16,7 @@ describe('Job Sidebar Retry Button', () => { wrapper = shallowMountExtended(JobsSidebarRetryButton, { propsData: { href: job.retry_path, + isManualJob: false, modalId: 'modal-id', ...props, }, diff --git a/spec/frontend/jobs/components/job/legacy_manual_variables_form_spec.js b/spec/frontend/jobs/components/job/legacy_manual_variables_form_spec.js deleted file mode 100644 index 184562b2968..00000000000 --- a/spec/frontend/jobs/components/job/legacy_manual_variables_form_spec.js +++ /dev/null @@ -1,156 +0,0 @@ -import { GlSprintf, GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import LegacyManualVariablesForm from '~/jobs/components/job/legacy_manual_variables_form.vue'; - -Vue.use(Vuex); - -describe('Manual Variables Form', () => { - let wrapper; - let store; - - const requiredProps = { - action: { - path: '/play', - method: 'post', - button_title: 'Trigger this manual action', - }, - }; - - const createComponent = (props = {}) => { - store = new Vuex.Store({ - actions: { - triggerManualJob: jest.fn(), - }, - }); - - wrapper = extendedWrapper( - mount(LegacyManualVariablesForm, { - propsData: { ...requiredProps, ...props }, - store, - stubs: { - GlSprintf, - }, - }), - ); - }; - - const findHelpText = () => wrapper.findComponent(GlSprintf); - const findHelpLink = () => wrapper.findComponent(GlLink); - - const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); - const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); - const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn'); - const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder'); - const findCiVariableKey = () => wrapper.findByTestId('ci-variable-key'); - const findAllCiVariableKeys = () => wrapper.findAllByTestId('ci-variable-key'); - const findCiVariableValue = () => wrapper.findByTestId('ci-variable-value'); - const findAllVariables = () => wrapper.findAllByTestId('ci-variable-row'); - - const setCiVariableKey = () => { - findCiVariableKey().setValue('new key'); - findCiVariableKey().vm.$emit('change'); - nextTick(); - }; - - const setCiVariableKeyByPosition = (position, value) => { - findAllCiVariableKeys().at(position).setValue(value); - findAllCiVariableKeys().at(position).vm.$emit('change'); - nextTick(); - }; - - beforeEach(() => { - createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('creates a new variable when user enters a new key value', async () => { - expect(findAllVariables()).toHaveLength(1); - - await setCiVariableKey(); - - expect(findAllVariables()).toHaveLength(2); - }); - - it('does not create extra empty variables', async () => { - expect(findAllVariables()).toHaveLength(1); - - await setCiVariableKey(); - - expect(findAllVariables()).toHaveLength(2); - - await setCiVariableKey(); - - expect(findAllVariables()).toHaveLength(2); - }); - - it('removes the correct variable row', async () => { - const variableKeyNameOne = 'key-one'; - const variableKeyNameThree = 'key-three'; - - await setCiVariableKeyByPosition(0, variableKeyNameOne); - - await setCiVariableKeyByPosition(1, 'key-two'); - - await setCiVariableKeyByPosition(2, variableKeyNameThree); - - expect(findAllVariables()).toHaveLength(4); - - await findAllDeleteVarBtns().at(1).trigger('click'); - - expect(findAllVariables()).toHaveLength(3); - - expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); - expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); - expect(findAllCiVariableKeys().at(2).element.value).toBe(''); - }); - - it('trigger button is disabled after trigger action', async () => { - expect(findTriggerBtn().props('disabled')).toBe(false); - - await findTriggerBtn().trigger('click'); - - expect(findTriggerBtn().props('disabled')).toBe(true); - }); - - it('delete variable button should only show when there is more than one variable', async () => { - expect(findDeleteVarBtn().exists()).toBe(false); - - await setCiVariableKey(); - - expect(findDeleteVarBtn().exists()).toBe(true); - }); - - it('delete variable button placeholder should only exist when a user cannot remove', async () => { - expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); - }); - - it('renders help text with provided link', () => { - expect(findHelpText().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe( - '/help/ci/variables/index#add-a-cicd-variable-to-a-project', - ); - }); - - it('passes variables in correct format', async () => { - jest.spyOn(store, 'dispatch'); - - await setCiVariableKey(); - - await findCiVariableValue().setValue('new value'); - - await findTriggerBtn().trigger('click'); - - expect(store.dispatch).toHaveBeenCalledWith('triggerManualJob', [ - { - key: 'new key', - secret_value: 'new value', - }, - ]); - }); -}); diff --git a/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js b/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js deleted file mode 100644 index 95eb10118ee..00000000000 --- a/spec/frontend/jobs/components/job/legacy_sidebar_header_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import JobRetryButton from '~/jobs/components/job/sidebar/job_sidebar_retry_button.vue'; -import LegacySidebarHeader from '~/jobs/components/job/sidebar/legacy_sidebar_header.vue'; -import createStore from '~/jobs/store'; -import job, { failedJobStatus } from '../../mock_data'; - -describe('Legacy Sidebar Header', () => { - let store; - let wrapper; - - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findRetryButton = () => wrapper.findComponent(JobRetryButton); - const findEraseLink = () => wrapper.findByTestId('job-log-erase-link'); - - const createWrapper = (props) => { - store = createStore(); - - wrapper = extendedWrapper( - shallowMount(LegacySidebarHeader, { - propsData: { - job, - ...props, - }, - store, - }), - ); - }; - - afterEach(() => { - wrapper.destroy(); - }); - - describe('when job log is erasable', () => { - const path = '/root/ci-project/-/jobs/1447/erase'; - - beforeEach(() => { - createWrapper({ - erasePath: path, - }); - }); - - it('renders erase job link', () => { - expect(findEraseLink().exists()).toBe(true); - }); - - it('erase job link has correct path', () => { - expect(findEraseLink().attributes('href')).toBe(path); - }); - }); - - describe('when job log is not erasable', () => { - beforeEach(() => { - createWrapper(); - }); - - it('does not render erase button', () => { - expect(findEraseLink().exists()).toBe(false); - }); - }); - - describe('when the job is retryable', () => { - beforeEach(() => { - createWrapper(); - }); - - it('should render the retry button', () => { - expect(findRetryButton().props('href')).toBe(job.retry_path); - }); - - it('should have a different label when the job status is passed', () => { - expect(findRetryButton().attributes('title')).toBe( - LegacySidebarHeader.i18n.runAgainJobButtonLabel, - ); - }); - }); - - describe('when there is no retry path', () => { - it('should not render a retry button', async () => { - const copy = { ...job, retry_path: null }; - createWrapper({ job: copy }); - - expect(findRetryButton().exists()).toBe(false); - }); - }); - - describe('when the job is cancelable', () => { - beforeEach(() => { - createWrapper(); - }); - - it('should render link to cancel job', () => { - expect(findCancelButton().props('icon')).toBe('cancel'); - expect(findCancelButton().attributes('href')).toBe(job.cancel_path); - }); - }); - - describe('when the job is failed', () => { - describe('retry button', () => { - it('should have a different label when the job status is failed', () => { - createWrapper({ job: { ...job, status: failedJobStatus } }); - - expect(findRetryButton().attributes('title')).toBe( - LegacySidebarHeader.i18n.retryJobButtonLabel, - ); - }); - }); - }); -}); diff --git a/spec/frontend/jobs/components/job/manual_variables_form_spec.js b/spec/frontend/jobs/components/job/manual_variables_form_spec.js index 5806f9f75f9..45a1e9dca76 100644 --- a/spec/frontend/jobs/components/job/manual_variables_form_spec.js +++ b/spec/frontend/jobs/components/job/manual_variables_form_spec.js @@ -1,46 +1,71 @@ import { GlSprintf, GlLink } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import Vue, { nextTick } from 'vue'; -import Vuex from 'vuex'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import { createLocalVue } from '@vue/test-utils'; +import VueApollo from 'vue-apollo'; +import { nextTick } from 'vue'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import { GRAPHQL_ID_TYPES } from '~/jobs/constants'; +import waitForPromises from 'helpers/wait_for_promises'; import ManualVariablesForm from '~/jobs/components/job/manual_variables_form.vue'; - -Vue.use(Vuex); +import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; +import retryJobMutation from '~/jobs/components/job/graphql/mutations/job_retry_with_variables.mutation.graphql'; +import { + mockFullPath, + mockId, + mockJobResponse, + mockJobWithVariablesResponse, + mockJobMutationData, +} from './mock_data'; + +const localVue = createLocalVue(); +localVue.use(VueApollo); + +const defaultProvide = { + projectPath: mockFullPath, +}; describe('Manual Variables Form', () => { let wrapper; - let store; - - const requiredProps = { - action: { - path: '/play', - method: 'post', - button_title: 'Trigger this manual action', - }, + let mockApollo; + let getJobQueryResponse; + + const createComponent = ({ options = {}, props = {} } = {}) => { + wrapper = mountExtended(ManualVariablesForm, { + propsData: { + ...props, + jobId: mockId, + isRetryable: true, + }, + provide: { + ...defaultProvide, + }, + ...options, + }); }; - const createComponent = (props = {}) => { - store = new Vuex.Store({ - actions: { - triggerManualJob: jest.fn(), - }, + const createComponentWithApollo = async ({ props = {} } = {}) => { + const requestHandlers = [[getJobQuery, getJobQueryResponse]]; + + mockApollo = createMockApollo(requestHandlers); + + const options = { + localVue, + apolloProvider: mockApollo, + }; + + createComponent({ + props, + options, }); - wrapper = extendedWrapper( - mount(ManualVariablesForm, { - propsData: { ...requiredProps, ...props }, - store, - stubs: { - GlSprintf, - }, - }), - ); + return waitForPromises(); }; const findHelpText = () => wrapper.findComponent(GlSprintf); const findHelpLink = () => wrapper.findComponent(GlLink); - - const findTriggerBtn = () => wrapper.findByTestId('trigger-manual-job-btn'); + const findCancelBtn = () => wrapper.findByTestId('cancel-btn'); + const findRerunBtn = () => wrapper.findByTestId('run-manual-job-btn'); const findDeleteVarBtn = () => wrapper.findByTestId('delete-variable-btn'); const findAllDeleteVarBtns = () => wrapper.findAllByTestId('delete-variable-btn'); const findDeleteVarBtnPlaceholder = () => wrapper.findByTestId('delete-variable-btn-placeholder'); @@ -62,95 +87,134 @@ describe('Manual Variables Form', () => { }; beforeEach(() => { - createComponent(); + getJobQueryResponse = jest.fn(); }); afterEach(() => { wrapper.destroy(); }); - it('creates a new variable when user enters a new key value', async () => { - expect(findAllVariables()).toHaveLength(1); + describe('when page renders', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobResponse); + await createComponentWithApollo(); + }); + + it('renders help text with provided link', () => { + expect(findHelpText().exists()).toBe(true); + expect(findHelpLink().attributes('href')).toBe( + '/help/ci/variables/index#add-a-cicd-variable-to-a-project', + ); + }); + + it('renders buttons', () => { + expect(findCancelBtn().exists()).toBe(true); + expect(findRerunBtn().exists()).toBe(true); + }); + }); + + describe('when job has variables', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobWithVariablesResponse); + await createComponentWithApollo(); + }); - await setCiVariableKey(); + it('sets manual job variables', () => { + const queryKey = mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].key; + const queryValue = + mockJobWithVariablesResponse.data.project.job.manualVariables.nodes[0].value; - expect(findAllVariables()).toHaveLength(2); + expect(findCiVariableKey().element.value).toBe(queryKey); + expect(findCiVariableValue().element.value).toBe(queryValue); + }); }); - it('does not create extra empty variables', async () => { - expect(findAllVariables()).toHaveLength(1); + describe('when mutation fires', () => { + beforeEach(async () => { + await createComponentWithApollo(); + jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockJobMutationData); + }); - await setCiVariableKey(); + it('passes variables in correct format', async () => { + await setCiVariableKey(); - expect(findAllVariables()).toHaveLength(2); + await findCiVariableValue().setValue('new value'); - await setCiVariableKey(); + await findRerunBtn().vm.$emit('click'); - expect(findAllVariables()).toHaveLength(2); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledTimes(1); + expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ + mutation: retryJobMutation, + variables: { + id: convertToGraphQLId(GRAPHQL_ID_TYPES.ciBuild, mockId), + variables: [ + { + key: 'new key', + value: 'new value', + }, + ], + }, + }); + }); }); - it('removes the correct variable row', async () => { - const variableKeyNameOne = 'key-one'; - const variableKeyNameThree = 'key-three'; + describe('updating variables in UI', () => { + beforeEach(async () => { + getJobQueryResponse.mockResolvedValue(mockJobResponse); + await createComponentWithApollo(); + }); - await setCiVariableKeyByPosition(0, variableKeyNameOne); + it('creates a new variable when user enters a new key value', async () => { + expect(findAllVariables()).toHaveLength(1); - await setCiVariableKeyByPosition(1, 'key-two'); + await setCiVariableKey(); - await setCiVariableKeyByPosition(2, variableKeyNameThree); + expect(findAllVariables()).toHaveLength(2); + }); - expect(findAllVariables()).toHaveLength(4); + it('does not create extra empty variables', async () => { + expect(findAllVariables()).toHaveLength(1); - await findAllDeleteVarBtns().at(1).trigger('click'); + await setCiVariableKey(); - expect(findAllVariables()).toHaveLength(3); + expect(findAllVariables()).toHaveLength(2); - expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); - expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); - expect(findAllCiVariableKeys().at(2).element.value).toBe(''); - }); + await setCiVariableKey(); - it('trigger button is disabled after trigger action', async () => { - expect(findTriggerBtn().props('disabled')).toBe(false); + expect(findAllVariables()).toHaveLength(2); + }); - await findTriggerBtn().trigger('click'); + it('removes the correct variable row', async () => { + const variableKeyNameOne = 'key-one'; + const variableKeyNameThree = 'key-three'; - expect(findTriggerBtn().props('disabled')).toBe(true); - }); + await setCiVariableKeyByPosition(0, variableKeyNameOne); - it('delete variable button should only show when there is more than one variable', async () => { - expect(findDeleteVarBtn().exists()).toBe(false); + await setCiVariableKeyByPosition(1, 'key-two'); - await setCiVariableKey(); + await setCiVariableKeyByPosition(2, variableKeyNameThree); - expect(findDeleteVarBtn().exists()).toBe(true); - }); + expect(findAllVariables()).toHaveLength(4); - it('delete variable button placeholder should only exist when a user cannot remove', async () => { - expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); - }); + await findAllDeleteVarBtns().at(1).trigger('click'); - it('renders help text with provided link', () => { - expect(findHelpText().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe( - '/help/ci/variables/index#add-a-cicd-variable-to-a-project', - ); - }); + expect(findAllVariables()).toHaveLength(3); - it('passes variables in correct format', async () => { - jest.spyOn(store, 'dispatch'); + expect(findAllCiVariableKeys().at(0).element.value).toBe(variableKeyNameOne); + expect(findAllCiVariableKeys().at(1).element.value).toBe(variableKeyNameThree); + expect(findAllCiVariableKeys().at(2).element.value).toBe(''); + }); - await setCiVariableKey(); + it('delete variable button should only show when there is more than one variable', async () => { + expect(findDeleteVarBtn().exists()).toBe(false); - await findCiVariableValue().setValue('new value'); + await setCiVariableKey(); - await findTriggerBtn().trigger('click'); + expect(findDeleteVarBtn().exists()).toBe(true); + }); - expect(store.dispatch).toHaveBeenCalledWith('triggerManualJob', [ - { - key: 'new key', - secret_value: 'new value', - }, - ]); + it('delete variable button placeholder should only exist when a user cannot remove', async () => { + expect(findDeleteVarBtnPlaceholder().exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/jobs/components/job/mock_data.js b/spec/frontend/jobs/components/job/mock_data.js new file mode 100644 index 00000000000..9596e859475 --- /dev/null +++ b/spec/frontend/jobs/components/job/mock_data.js @@ -0,0 +1,76 @@ +export const mockFullPath = 'Commit451/lab-coat'; +export const mockId = 401; + +export const mockJobResponse = { + data: { + project: { + id: 'gid://gitlab/Project/4', + job: { + id: 'gid://gitlab/Ci::Build/401', + manualJob: true, + manualVariables: { + nodes: [], + __typename: 'CiManualVariableConnection', + }, + name: 'manual_job', + retryable: true, + status: 'SUCCESS', + __typename: 'CiJob', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobWithVariablesResponse = { + data: { + project: { + id: 'gid://gitlab/Project/4', + job: { + id: 'gid://gitlab/Ci::Build/401', + manualJob: true, + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/150', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + name: 'manual_job', + retryable: true, + status: 'SUCCESS', + __typename: 'CiJob', + }, + __typename: 'Project', + }, + }, +}; + +export const mockJobMutationData = { + data: { + jobRetry: { + job: { + id: 'gid://gitlab/Ci::Build/401', + manualVariables: { + nodes: [ + { + id: 'gid://gitlab/Ci::JobVariable/151', + key: 'new key', + value: 'new value', + __typename: 'CiManualVariable', + }, + ], + __typename: 'CiManualVariableConnection', + }, + webPath: '/Commit451/lab-coat/-/jobs/401', + __typename: 'CiJob', + }, + errors: [], + __typename: 'JobRetryPayload', + }, + }, +}; diff --git a/spec/frontend/jobs/components/job/sidebar_header_spec.js b/spec/frontend/jobs/components/job/sidebar_header_spec.js index cb32ca9d3dc..da97945f9bf 100644 --- a/spec/frontend/jobs/components/job/sidebar_header_spec.js +++ b/spec/frontend/jobs/components/job/sidebar_header_spec.js @@ -1,91 +1,87 @@ -import { shallowMount } from '@vue/test-utils'; -import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import SidebarHeader from '~/jobs/components/job/sidebar/sidebar_header.vue'; import JobRetryButton from '~/jobs/components/job/sidebar/job_sidebar_retry_button.vue'; -import LegacySidebarHeader from '~/jobs/components/job/sidebar/legacy_sidebar_header.vue'; -import createStore from '~/jobs/store'; -import job from '../../mock_data'; +import getJobQuery from '~/jobs/components/job/graphql/queries/get_job.query.graphql'; +import { mockFullPath, mockId, mockJobResponse } from './mock_data'; -describe('Legacy Sidebar Header', () => { - let store; +Vue.use(VueApollo); + +const defaultProvide = { + projectPath: mockFullPath, +}; + +describe('Sidebar Header', () => { let wrapper; - const findCancelButton = () => wrapper.findByTestId('cancel-button'); - const findRetryButton = () => wrapper.findComponent(JobRetryButton); - const findEraseLink = () => wrapper.findByTestId('job-log-erase-link'); - - const createWrapper = (props) => { - store = createStore(); - - wrapper = extendedWrapper( - shallowMount(LegacySidebarHeader, { - propsData: { - job, - ...props, - }, - store, - }), - ); + const createComponent = ({ options = {}, props = {}, restJob = {} } = {}) => { + wrapper = shallowMountExtended(SidebarHeader, { + propsData: { + ...props, + jobId: mockId, + restJob, + }, + provide: { + ...defaultProvide, + }, + ...options, + }); }; - afterEach(() => { - wrapper.destroy(); - }); + const createComponentWithApollo = async ({ props = {}, restJob = {} } = {}) => { + const getJobQueryResponse = jest.fn().mockResolvedValue(mockJobResponse); - describe('when job log is erasable', () => { - const path = '/root/ci-project/-/jobs/1447/erase'; + const requestHandlers = [[getJobQuery, getJobQueryResponse]]; - beforeEach(() => { - createWrapper({ - erasePath: path, - }); - }); + const apolloProvider = createMockApollo(requestHandlers); - it('renders erase job link', () => { - expect(findEraseLink().exists()).toBe(true); - }); + const options = { + apolloProvider, + }; - it('erase job link has correct path', () => { - expect(findEraseLink().attributes('href')).toBe(path); + createComponent({ + props, + restJob, + options, }); - }); - describe('when job log is not erasable', () => { - beforeEach(() => { - createWrapper(); - }); + return waitForPromises(); + }; - it('does not render erase button', () => { - expect(findEraseLink().exists()).toBe(false); - }); - }); + const findCancelButton = () => wrapper.findByTestId('cancel-button'); + const findEraseButton = () => wrapper.findByTestId('job-log-erase-link'); + const findJobName = () => wrapper.findByTestId('job-name'); + const findRetryButton = () => wrapper.findComponent(JobRetryButton); - describe('when the job is retryable', () => { - beforeEach(() => { - createWrapper(); + describe('when rendering contents', () => { + it('renders the correct job name', async () => { + await createComponentWithApollo(); + expect(findJobName().text()).toBe(mockJobResponse.data.project.job.name); }); - it('should render the retry button', () => { - expect(findRetryButton().props('href')).toBe(job.retry_path); + it('does not render buttons with no paths', async () => { + await createComponentWithApollo(); + expect(findCancelButton().exists()).toBe(false); + expect(findEraseButton().exists()).toBe(false); + expect(findRetryButton().exists()).toBe(false); }); - }); - - describe('when there is no retry path', () => { - it('should not render a retry button', async () => { - const copy = { ...job, retry_path: null }; - createWrapper({ job: copy }); - expect(findRetryButton().exists()).toBe(false); + it('renders a retry button with a path', async () => { + await createComponentWithApollo({ restJob: { retry_path: 'retry/path' } }); + expect(findRetryButton().exists()).toBe(true); }); - }); - describe('when the job is cancelable', () => { - beforeEach(() => { - createWrapper(); + it('renders a cancel button with a path', async () => { + await createComponentWithApollo({ restJob: { cancel_path: 'cancel/path' } }); + expect(findCancelButton().exists()).toBe(true); }); - it('should render link to cancel job', () => { - expect(findCancelButton().props('icon')).toBe('cancel'); - expect(findCancelButton().attributes('href')).toBe(job.cancel_path); + it('renders an erase button with a path', async () => { + await createComponentWithApollo({ restJob: { erase_path: 'erase/path' } }); + expect(findEraseButton().exists()).toBe(true); }); }); }); diff --git a/spec/frontend/jobs/mock_data.js b/spec/frontend/jobs/mock_data.js index a7fe6d5a626..9abd610c26d 100644 --- a/spec/frontend/jobs/mock_data.js +++ b/spec/frontend/jobs/mock_data.js @@ -3,6 +3,7 @@ import mockJobsPaginated from 'test_fixtures/graphql/jobs/get_jobs.query.graphql import mockJobs from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.json'; import mockJobsAsGuest from 'test_fixtures/graphql/jobs/get_jobs.query.graphql.as_guest.json'; import { TEST_HOST } from 'spec/test_constants'; +import { TOKEN_TYPE_STATUS } from '~/vue_shared/components/filtered_search_bar/constants'; const threeWeeksAgo = new Date(); threeWeeksAgo.setDate(threeWeeksAgo.getDate() - 21); @@ -1365,7 +1366,10 @@ export const CIJobConnectionExistingCache = { statuses: 'PENDING', }; -export const mockFailedSearchToken = { type: 'status', value: { data: 'FAILED', operator: '=' } }; +export const mockFailedSearchToken = { + type: TOKEN_TYPE_STATUS, + value: { data: 'FAILED', operator: '=' }, +}; export const retryMutationResponse = { data: { diff --git a/spec/frontend/language_switcher/components/app_spec.js b/spec/frontend/language_switcher/components/app_spec.js new file mode 100644 index 00000000000..6a1b94cd813 --- /dev/null +++ b/spec/frontend/language_switcher/components/app_spec.js @@ -0,0 +1,62 @@ +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import LanguageSwitcherApp from '~/language_switcher/components/app.vue'; +import { PREFERRED_LANGUAGE_COOKIE_KEY } from '~/language_switcher/constants'; +import * as utils from '~/lib/utils/common_utils'; +import { locales, ES, EN } from '../mock_data'; + +jest.mock('~/lib/utils/common_utils'); + +describe('<LanguageSwitcher />', () => { + let wrapper; + + const createComponent = (props = {}) => { + wrapper = mountExtended(LanguageSwitcherApp, { + provide: { + locales, + preferredLocale: EN, + ...props, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + const getPreferredLanguage = () => wrapper.find('.gl-dropdown-button-text').text(); + const findLanguageDropdownItem = (code) => wrapper.findByTestId(`language_switcher_lang_${code}`); + + it('preferred language', () => { + expect(getPreferredLanguage()).toBe(EN.text); + + createComponent({ + preferredLocale: ES, + }); + + expect(getPreferredLanguage()).toBe(ES.text); + }); + + it('switches language', async () => { + // because window.location is **READ ONLY** we cannot simply use + // jest.spyOn to mock it. + const originalLocation = window.location; + delete window.location; + window.location = {}; + window.location.reload = jest.fn(); + const reloadSpy = window.location.reload; + expect(reloadSpy).not.toHaveBeenCalled(); + expect(utils.setCookie).not.toHaveBeenCalled(); + + const es = findLanguageDropdownItem(ES.value); + + await es.trigger('click'); + + expect(reloadSpy).toHaveBeenCalled(); + expect(utils.setCookie).toHaveBeenCalledWith(PREFERRED_LANGUAGE_COOKIE_KEY, ES.value); + window.location = originalLocation; + }); +}); diff --git a/spec/frontend/language_switcher/mock_data.js b/spec/frontend/language_switcher/mock_data.js new file mode 100644 index 00000000000..548bddf0173 --- /dev/null +++ b/spec/frontend/language_switcher/mock_data.js @@ -0,0 +1,26 @@ +export const EN = { + value: 'en', + text: 'English', +}; + +export const ZH_CN = { + value: 'zh_CN', + text: '简体中文', +}; + +export const ES = { + value: 'es', + text: 'Espanol', +}; + +export const ZH_HK = { + value: 'zh_HK', + text: '繁体中文(香港)', +}; + +export const ZH_TW = { + value: 'zh_TW', + text: '繁体中文(台湾)', +}; + +export const locales = [EN, ZH_CN, ES, ZH_HK, ZH_TW]; diff --git a/spec/frontend/lib/dompurify_spec.js b/spec/frontend/lib/dompurify_spec.js index 412408ce377..f767a673553 100644 --- a/spec/frontend/lib/dompurify_spec.js +++ b/spec/frontend/lib/dompurify_spec.js @@ -94,6 +94,11 @@ describe('~/lib/dompurify', () => { expect(sanitize('<link rel="stylesheet" href="styles.css">')).toBe(''); }); + it("doesn't allow form tags", () => { + expect(sanitize('<form>')).toBe(''); + expect(sanitize('<form method="post" action="path"></form>')).toBe(''); + }); + describe.each` type | gon ${'root'} | ${rootGon} diff --git a/spec/frontend/lib/utils/common_utils_spec.js b/spec/frontend/lib/utils/common_utils_spec.js index 947c38c8ae8..08ba78cddff 100644 --- a/spec/frontend/lib/utils/common_utils_spec.js +++ b/spec/frontend/lib/utils/common_utils_spec.js @@ -1,4 +1,5 @@ import * as commonUtils from '~/lib/utils/common_utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; describe('common_utils', () => { describe('getPagePath', () => { @@ -1069,4 +1070,35 @@ describe('common_utils', () => { expect(result).toEqual([{ hello: '' }, { helloWorld: '' }]); }); }); + + describe('useNewFonts', () => { + let beforeGon; + const beforeLocation = window.location.href; + + beforeEach(() => { + window.gon = window.gon || {}; + beforeGon = { ...window.gon }; + }); + + describe.each` + featureFlag | queryParameter | fontEnabled + ${false} | ${false} | ${false} + ${true} | ${false} | ${true} + ${false} | ${true} | ${true} + `('new font', ({ featureFlag, queryParameter, fontEnabled }) => { + it(`will ${fontEnabled ? '' : 'NOT '}be applied when feature flag is ${ + featureFlag ? '' : 'NOT ' + }set and query parameter is ${queryParameter ? '' : 'NOT '}present`, () => { + const search = queryParameter ? `?new_fonts` : ''; + setWindowLocation(search); + window.gon = { features: { newFonts: featureFlag } }; + expect(commonUtils.useNewFonts()).toBe(fontEnabled); + }); + }); + + afterEach(() => { + window.gon = beforeGon; + setWindowLocation(beforeLocation); + }); + }); }); diff --git a/spec/frontend/lib/utils/create_and_submit_form_spec.js b/spec/frontend/lib/utils/create_and_submit_form_spec.js new file mode 100644 index 00000000000..9f2472c60f7 --- /dev/null +++ b/spec/frontend/lib/utils/create_and_submit_form_spec.js @@ -0,0 +1,73 @@ +import csrf from '~/lib/utils/csrf'; +import { TEST_HOST } from 'helpers/test_constants'; +import { createAndSubmitForm } from '~/lib/utils/create_and_submit_form'; +import { joinPaths } from '~/lib/utils/url_utility'; + +const TEST_URL = '/foo/bar/lorem'; +const TEST_DATA = { + 'test_thing[0]': 'Lorem Ipsum', + 'test_thing[1]': 'Dolar Sit', + x: 123, +}; +const TEST_CSRF = 'testcsrf00=='; + +describe('~/lib/utils/create_and_submit_form', () => { + let submitSpy; + + const findForm = () => document.querySelector('form'); + const findInputsModel = () => + Array.from(findForm().querySelectorAll('input')).map((inputEl) => ({ + type: inputEl.type, + name: inputEl.name, + value: inputEl.value, + })); + + beforeEach(() => { + submitSpy = jest.spyOn(HTMLFormElement.prototype, 'submit'); + document.head.innerHTML = `<meta name="csrf-token" content="${TEST_CSRF}">`; + csrf.init(); + }); + + afterEach(() => { + document.head.innerHTML = ''; + document.body.innerHTML = ''; + }); + + describe('default', () => { + beforeEach(() => { + createAndSubmitForm({ + url: TEST_URL, + data: TEST_DATA, + }); + }); + + it('creates form', () => { + const form = findForm(); + + expect(form.action).toBe(joinPaths(TEST_HOST, TEST_URL)); + expect(form.method).toBe('post'); + expect(form.style).toMatchObject({ + display: 'none', + }); + }); + + it('creates inputs', () => { + expect(findInputsModel()).toEqual([ + ...Object.keys(TEST_DATA).map((key) => ({ + type: 'hidden', + name: key, + value: String(TEST_DATA[key]), + })), + { + type: 'hidden', + name: 'authenticity_token', + value: TEST_CSRF, + }, + ]); + }); + + it('submits form', () => { + expect(submitSpy).toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/lib/utils/dom_utils_spec.js b/spec/frontend/lib/utils/dom_utils_spec.js index d6bac935970..172f8972653 100644 --- a/spec/frontend/lib/utils/dom_utils_spec.js +++ b/spec/frontend/lib/utils/dom_utils_spec.js @@ -10,6 +10,7 @@ import { getParents, getParentByTagName, setAttributes, + replaceCommentsWith, } from '~/lib/utils/dom_utils'; const TEST_MARGIN = 5; @@ -263,4 +264,21 @@ describe('DOM Utils', () => { expect(getContentWrapperHeight('.does-not-exist')).toBe(''); }); }); + + describe('replaceCommentsWith', () => { + let div; + beforeEach(() => { + div = document.createElement('div'); + }); + + it('replaces the comments in a DOM node with an element', () => { + div.innerHTML = '<h1> hi there <!-- some comment --> <p> <!-- another comment -->'; + + replaceCommentsWith(div, 'comment'); + + expect(div.innerHTML).toBe( + '<h1> hi there <comment> some comment </comment> <p> <comment> another comment </comment></p></h1>', + ); + }); + }); }); diff --git a/spec/frontend/lib/utils/poll_until_complete_spec.js b/spec/frontend/lib/utils/poll_until_complete_spec.js index 7509f954a84..3ce17ecfc8c 100644 --- a/spec/frontend/lib/utils/poll_until_complete_spec.js +++ b/spec/frontend/lib/utils/poll_until_complete_spec.js @@ -1,7 +1,7 @@ import AxiosMockAdapter from 'axios-mock-adapter'; import { TEST_HOST } from 'helpers/test_constants'; import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; +import httpStatusCodes, { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import pollUntilComplete from '~/lib/utils/poll_until_complete'; const endpoint = `${TEST_HOST}/foo`; @@ -37,7 +37,7 @@ describe('pollUntilComplete', () => { beforeEach(() => { mock .onGet(endpoint) - .replyOnce(httpStatusCodes.NO_CONTENT, undefined, pollIntervalHeader) + .replyOnce(HTTP_STATUS_NO_CONTENT, undefined, pollIntervalHeader) .onGet(endpoint) .replyOnce(httpStatusCodes.OK, mockData); }); diff --git a/spec/frontend/lib/utils/url_utility_spec.js b/spec/frontend/lib/utils/url_utility_spec.js index 2c6b603197d..6afdab455a6 100644 --- a/spec/frontend/lib/utils/url_utility_spec.js +++ b/spec/frontend/lib/utils/url_utility_spec.js @@ -759,6 +759,19 @@ describe('URL utility', () => { }); }); + describe('cleanEndingSeparator', () => { + it.each` + path | expected + ${'foo/bar'} | ${'foo/bar'} + ${'/foo/bar/'} | ${'/foo/bar'} + ${'foo/bar//'} | ${'foo/bar'} + ${'foo/bar/./'} | ${'foo/bar/.'} + ${''} | ${''} + `('$path becomes $expected', ({ path, expected }) => { + expect(urlUtils.cleanEndingSeparator(path)).toBe(expected); + }); + }); + describe('joinPaths', () => { it.each` paths | expected diff --git a/spec/frontend/listbox/index_spec.js b/spec/frontend/listbox/index_spec.js index fd41531796b..0816152f4e3 100644 --- a/spec/frontend/listbox/index_spec.js +++ b/spec/frontend/listbox/index_spec.js @@ -1,6 +1,6 @@ import { nextTick } from 'vue'; import { getAllByRole, getByTestId } from '@testing-library/dom'; -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { createWrapper } from '@vue/test-utils'; import { initListbox, parseAttributes } from '~/listbox'; import { getFixture, setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; @@ -41,7 +41,7 @@ describe('initListbox', () => { describe('given a valid element', () => { let onChangeSpy; - const listbox = () => createWrapper(instance).findComponent(GlListbox); + const listbox = () => createWrapper(instance).findComponent(GlCollapsibleListbox); const findToggleButton = () => getByTestId(document.body, 'base-dropdown-toggle'); const findSelectedItems = () => getAllByRole(document.body, 'option', { selected: true }); diff --git a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js index 4580fdb06f2..f346967121c 100644 --- a/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js +++ b/spec/frontend/members/components/filter_sort/members_filtered_search_bar_spec.js @@ -9,6 +9,7 @@ import { FILTERED_SEARCH_TOKEN_TWO_FACTOR, FILTERED_SEARCH_TOKEN_WITH_INHERITED_PERMISSIONS, } from '~/members/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; jest.mock('~/lib/utils/url_utility', () => { @@ -130,7 +131,7 @@ describe('MembersFilteredSearchBar', () => { expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'foobar', }, @@ -145,7 +146,7 @@ describe('MembersFilteredSearchBar', () => { expect(findFilteredSearchBar().props('initialFilterValue')).toEqual([ { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'foo bar baz', }, @@ -174,7 +175,7 @@ describe('MembersFilteredSearchBar', () => { findFilteredSearchBar().vm.$emit('onFilter', [ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } }, - { type: 'filtered-search-term', value: { data: 'foobar' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'foobar' } }, ]); expect(redirectTo).toHaveBeenCalledWith( @@ -187,7 +188,7 @@ describe('MembersFilteredSearchBar', () => { findFilteredSearchBar().vm.$emit('onFilter', [ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } }, - { type: 'filtered-search-term', value: { data: 'foo bar baz' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'foo bar baz' } }, ]); expect(redirectTo).toHaveBeenCalledWith( @@ -202,7 +203,7 @@ describe('MembersFilteredSearchBar', () => { findFilteredSearchBar().vm.$emit('onFilter', [ { type: FILTERED_SEARCH_TOKEN_TWO_FACTOR.type, value: { data: 'enabled', operator: '=' } }, - { type: 'filtered-search-term', value: { data: 'foobar' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'foobar' } }, ]); expect(redirectTo).toHaveBeenCalledWith( @@ -216,7 +217,7 @@ describe('MembersFilteredSearchBar', () => { createComponent(); findFilteredSearchBar().vm.$emit('onFilter', [ - { type: 'filtered-search-term', value: { data: 'foobar' } }, + { type: FILTERED_SEARCH_TERM, value: { data: 'foobar' } }, ]); expect(redirectTo).toHaveBeenCalledWith('https://localhost/?search=foobar&tab=invited'); diff --git a/spec/frontend/merge_request_tabs_spec.js b/spec/frontend/merge_request_tabs_spec.js index c6e90a4b20d..69ff5e47689 100644 --- a/spec/frontend/merge_request_tabs_spec.js +++ b/spec/frontend/merge_request_tabs_spec.js @@ -303,6 +303,7 @@ describe('MergeRequestTabs', () => { const tabContent = document.createElement('div'); beforeEach(() => { + $.fn.renderGFM = jest.fn(); jest.spyOn(mainContent, 'getBoundingClientRect').mockReturnValue({ top: 10 }); jest.spyOn(tabContent, 'getBoundingClientRect').mockReturnValue({ top: 100 }); jest.spyOn(window, 'scrollTo').mockImplementation(() => {}); diff --git a/spec/frontend/merge_requests/components/target_project_dropdown_spec.js b/spec/frontend/merge_requests/components/target_project_dropdown_spec.js new file mode 100644 index 00000000000..3fddbe7ae21 --- /dev/null +++ b/spec/frontend/merge_requests/components/target_project_dropdown_spec.js @@ -0,0 +1,80 @@ +import { mount } from '@vue/test-utils'; +import { GlCollapsibleListbox } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; +import axios from '~/lib/utils/axios_utils'; +import TargetProjectDropdown from '~/merge_requests/components/target_project_dropdown.vue'; + +let wrapper; +let mock; + +function factory() { + wrapper = mount(TargetProjectDropdown, { + provide: { + targetProjectsPath: '/gitlab-org/gitlab/target_projects', + currentProject: { value: 1, text: 'gitlab-org/gitlab' }, + }, + }); +} + +const findDropdown = () => wrapper.findComponent(GlCollapsibleListbox); + +describe('Merge requests target project dropdown component', () => { + beforeEach(() => { + mock = new MockAdapter(axios); + mock.onGet('/gitlab-org/gitlab/target_projects').reply(200, [ + { + id: 10, + name: 'Gitlab Test', + full_path: '/root/gitlab-test', + full_name: 'Administrator / Gitlab Test', + refs_url: '/root/gitlab-test/refs', + }, + { + id: 1, + name: 'Gitlab Test', + full_path: '/gitlab-org/gitlab-test', + full_name: 'Gitlab Org / Gitlab Test', + refs_url: '/gitlab-org/gitlab-test/refs', + }, + ]); + }); + + afterEach(() => { + wrapper.destroy(); + mock.restore(); + }); + + it('creates hidden input with currentProject ID', () => { + factory(); + + expect(wrapper.find('[data-testid="target-project-input"]').attributes('value')).toBe('1'); + }); + + it('renders list of projects', async () => { + factory(); + + wrapper.find('[data-testid="base-dropdown-toggle"]').trigger('click'); + + await waitForPromises(); + + expect(wrapper.findAll('li').length).toBe(2); + expect(wrapper.findAll('li').at(0).text()).toBe('root/gitlab-test'); + expect(wrapper.findAll('li').at(1).text()).toBe('gitlab-org/gitlab-test'); + }); + + it('searches projects', async () => { + factory(); + + wrapper.find('[data-testid="base-dropdown-toggle"]').trigger('click'); + + await waitForPromises(); + + findDropdown().vm.$emit('search', 'test'); + + jest.advanceTimersByTime(500); + await waitForPromises(); + + expect(mock.history.get[1].params).toEqual({ search: 'test' }); + }); +}); diff --git a/spec/frontend/milestones/components/milestone_combobox_spec.js b/spec/frontend/milestones/components/milestone_combobox_spec.js index ce5b2a1000b..c20c51db75e 100644 --- a/spec/frontend/milestones/components/milestone_combobox_spec.js +++ b/spec/frontend/milestones/components/milestone_combobox_spec.js @@ -346,7 +346,7 @@ describe('Milestone combobox component', () => { expect( findFirstProjectMilestonesDropdownItem() .find('svg') - .classes('gl-new-dropdown-item-check-icon'), + .classes('gl-dropdown-item-check-icon'), ).toBe(true); selectFirstProjectMilestone(); @@ -473,7 +473,7 @@ describe('Milestone combobox component', () => { expect( findFirstGroupMilestonesDropdownItem() .find('svg') - .classes('gl-new-dropdown-item-check-icon'), + .classes('gl-dropdown-item-check-icon'), ).toBe(true); selectFirstGroupMilestone(); diff --git a/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap new file mode 100644 index 00000000000..8af0753f929 --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_candidate_spec.js.snap @@ -0,0 +1,233 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`MlCandidate renders correctly 1`] = ` +<div> + <div + class="gl-alert gl-alert-warning" + > + <svg + aria-hidden="true" + class="gl-icon s16 gl-alert-icon" + data-testid="warning-icon" + role="img" + > + <use + href="#warning" + /> + </svg> + + <div + aria-live="assertive" + class="gl-alert-content" + role="alert" + > + <h2 + class="gl-alert-title" + > + Machine Learning Experiment Tracking is in Incubating Phase + </h2> + + <div + class="gl-alert-body" + > + + GitLab incubates features to explore new use cases. These features are updated regularly, and support is limited + + <a + class="gl-link" + href="https://about.gitlab.com/handbook/engineering/incubation/" + rel="noopener noreferrer" + target="_blank" + > + Learn more + </a> + </div> + + <div + class="gl-alert-actions" + > + <a + class="btn gl-alert-action btn-confirm btn-md gl-button" + href="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" + > + <!----> + + <!----> + + <span + class="gl-button-text" + > + + Feedback + + </span> + </a> + </div> + </div> + + <button + aria-label="Dismiss" + class="btn gl-dismiss-btn btn-default btn-sm gl-button btn-default-tertiary btn-icon" + type="button" + > + <!----> + + <svg + aria-hidden="true" + class="gl-button-icon gl-icon s16" + data-testid="close-icon" + role="img" + > + <use + href="#close" + /> + </svg> + + <!----> + </button> + </div> + + <h3> + + Model candidate details + + </h3> + + <table + class="candidate-details" + > + <tbody> + <tr + class="divider" + /> + + <tr> + <td + class="gl-text-secondary gl-font-weight-bold" + > + Info + </td> + + <td + class="gl-font-weight-bold" + > + ID + </td> + + <td> + candidate_iid + </td> + </tr> + + <tr> + <td /> + + <td + class="gl-font-weight-bold" + > + Status + </td> + + <td> + SUCCESS + </td> + </tr> + + <tr> + <td /> + + <td + class="gl-font-weight-bold" + > + Experiment + </td> + + <td> + <a + class="gl-link" + href="#" + > + The Experiment + </a> + </td> + </tr> + + <!----> + + <tr + class="divider" + /> + + <tr> + <td + class="gl-text-secondary gl-font-weight-bold" + > + + Parameters + + </td> + + <td + class="gl-font-weight-bold" + > + Algorithm + </td> + + <td> + Decision Tree + </td> + </tr> + <tr> + <td /> + + <td + class="gl-font-weight-bold" + > + MaxDepth + </td> + + <td> + 3 + </td> + </tr> + + <tr + class="divider" + /> + + <tr> + <td + class="gl-text-secondary gl-font-weight-bold" + > + + Metrics + + </td> + + <td + class="gl-font-weight-bold" + > + AUC + </td> + + <td> + .55 + </td> + </tr> + <tr> + <td /> + + <td + class="gl-font-weight-bold" + > + Accuracy + </td> + + <td> + .99 + </td> + </tr> + </tbody> + </table> +</div> +`; diff --git a/spec/frontend/ml/experiment_tracking/components/__snapshots__/experiment_spec.js.snap b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_experiment_spec.js.snap index 2eba8869535..e253a0afc6c 100644 --- a/spec/frontend/ml/experiment_tracking/components/__snapshots__/experiment_spec.js.snap +++ b/spec/frontend/ml/experiment_tracking/components/__snapshots__/ml_experiment_spec.js.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`ShowExperiment with candidates renders correctly 1`] = ` +exports[`MlExperiment with candidates renders correctly 1`] = ` <div> <div class="gl-alert gl-alert-warning" @@ -39,7 +39,7 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` rel="noopener noreferrer" target="_blank" > - Learn More + Learn more </a> </div> @@ -48,7 +48,7 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` > <a class="btn gl-alert-action btn-confirm btn-md gl-button" - href="https://gitlab.com/groups/gitlab-org/-/epics/8560" + href="https://gitlab.com/gitlab-org/gitlab/-/issues/381660" > <!----> @@ -58,7 +58,7 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` class="gl-button-text" > - Feedback and Updates + Feedback </span> </a> @@ -89,13 +89,13 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` <h3> - Experiment Candidates + Experiment candidates </h3> <table aria-busy="false" - aria-colcount="4" + aria-colcount="6" class="table b-table gl-table gl-mt-0!" role="table" > @@ -150,6 +150,24 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` Mae </div> </th> + <th + aria-colindex="5" + aria-label="Details" + class="" + role="columnheader" + scope="col" + > + <div /> + </th> + <th + aria-colindex="6" + aria-label="Artifact" + class="" + role="columnheader" + scope="col" + > + <div /> + </th> </tr> </thead> <tbody @@ -184,6 +202,32 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` class="" role="cell" /> + <td + aria-colindex="5" + class="" + role="cell" + > + <a + class="gl-link" + href="link_to_candidate1" + > + Details + </a> + </td> + <td + aria-colindex="6" + class="" + role="cell" + > + <a + class="gl-link" + href="link_to_artifact" + rel="noopener" + target="_blank" + > + Artifacts + </a> + </td> </tr> <tr class="" @@ -213,6 +257,23 @@ exports[`ShowExperiment with candidates renders correctly 1`] = ` class="" role="cell" /> + <td + aria-colindex="5" + class="" + role="cell" + > + <a + class="gl-link" + href="link_to_candidate2" + > + Details + </a> + </td> + <td + aria-colindex="6" + class="" + role="cell" + /> </tr> <!----> <!----> diff --git a/spec/frontend/ml/experiment_tracking/components/incubation_alert_spec.js b/spec/frontend/ml/experiment_tracking/components/incubation_alert_spec.js index e07a4ed816b..7dca360c7ee 100644 --- a/spec/frontend/ml/experiment_tracking/components/incubation_alert_spec.js +++ b/spec/frontend/ml/experiment_tracking/components/incubation_alert_spec.js @@ -15,7 +15,7 @@ describe('IncubationAlert', () => { it('displays link to issue', () => { expect(findButton().attributes().href).toBe( - 'https://gitlab.com/groups/gitlab-org/-/epics/8560', + 'https://gitlab.com/gitlab-org/gitlab/-/issues/381660', ); }); diff --git a/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js new file mode 100644 index 00000000000..4b16312815a --- /dev/null +++ b/spec/frontend/ml/experiment_tracking/components/ml_candidate_spec.js @@ -0,0 +1,43 @@ +import { GlAlert } from '@gitlab/ui'; +import { mountExtended } from 'helpers/vue_test_utils_helper'; +import MlCandidate from '~/ml/experiment_tracking/components/ml_candidate.vue'; + +describe('MlCandidate', () => { + let wrapper; + + const createWrapper = () => { + const candidate = { + params: [ + { name: 'Algorithm', value: 'Decision Tree' }, + { name: 'MaxDepth', value: '3' }, + ], + metrics: [ + { name: 'AUC', value: '.55' }, + { name: 'Accuracy', value: '.99' }, + ], + info: { + iid: 'candidate_iid', + artifact_link: 'path_to_artifact', + experiment_name: 'The Experiment', + experiment_path: 'path/to/experiment', + status: 'SUCCESS', + }, + }; + + return mountExtended(MlCandidate, { provide: { candidate } }); + }; + + const findAlert = () => wrapper.findComponent(GlAlert); + + it('shows incubation warning', () => { + wrapper = createWrapper(); + + expect(findAlert().exists()).toBe(true); + }); + + it('renders correctly', () => { + wrapper = createWrapper(); + + expect(wrapper.element).toMatchSnapshot(); + }); +}); diff --git a/spec/frontend/ml/experiment_tracking/components/experiment_spec.js b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js index af722d77532..50539440f25 100644 --- a/spec/frontend/ml/experiment_tracking/components/experiment_spec.js +++ b/spec/frontend/ml/experiment_tracking/components/ml_experiment_spec.js @@ -1,17 +1,17 @@ import { GlAlert } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; -import ShowExperiment from '~/ml/experiment_tracking/components/experiment.vue'; +import MlExperiment from '~/ml/experiment_tracking/components/ml_experiment.vue'; -describe('ShowExperiment', () => { +describe('MlExperiment', () => { let wrapper; const createWrapper = (candidates = [], metricNames = [], paramNames = []) => { - return mountExtended(ShowExperiment, { provide: { candidates, metricNames, paramNames } }); + return mountExtended(MlExperiment, { provide: { candidates, metricNames, paramNames } }); }; const findAlert = () => wrapper.findComponent(GlAlert); - const findEmptyState = () => wrapper.findByText('This Experiment has no logged Candidates'); + const findEmptyState = () => wrapper.findByText('This experiment has no logged candidates'); it('shows incubation warning', () => { wrapper = createWrapper(); @@ -31,8 +31,8 @@ describe('ShowExperiment', () => { it('renders correctly', () => { wrapper = createWrapper( [ - { rmse: 1, l1_ratio: 0.4 }, - { auc: 0.3, l1_ratio: 0.5 }, + { rmse: 1, l1_ratio: 0.4, details: 'link_to_candidate1', artifact: 'link_to_artifact' }, + { auc: 0.3, l1_ratio: 0.5, details: 'link_to_candidate2' }, ], ['rmse', 'auc', 'mae'], ['l1_ratio'], 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 263d6225a9f..3b4554700b4 100644 --- a/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap +++ b/spec/frontend/monitoring/components/__snapshots__/dashboard_template_spec.js.snap @@ -3,7 +3,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <div class="prometheus-graphs" - data-qa-selector="prometheus_graphs_content" data-testid="prometheus-graphs" environmentstate="available" metricsdashboardbasepath="/monitoring/monitor-project/-/metrics?environment=1" @@ -40,7 +39,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` > <dashboards-dropdown-stub class="flex-grow-1" - data-qa-selector="dashboards_filter_dropdown" defaultbranch="master" id="monitor-dashboards-dropdown" toggle-class="dropdown-menu-toggle" @@ -60,7 +58,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` class="flex-grow-1" clearalltext="Clear all" clearalltextclass="gl-px-5" - data-qa-selector="environments_dropdown" data-testid="environments-dropdown" headertext="" hideheaderborder="true" @@ -106,7 +103,6 @@ exports[`Dashboard template matches the default snapshot 1`] = ` <date-time-picker-stub class="flex-grow-1 show-last-dropdown" customenabled="true" - data-qa-selector="range_picker_dropdown" options="[object Object],[object Object],[object Object],[object Object],[object Object],[object Object],[object Object]" value="[object Object]" /> diff --git a/spec/frontend/monitoring/components/refresh_button_spec.js b/spec/frontend/monitoring/components/refresh_button_spec.js index e00736954a9..cb300870689 100644 --- a/spec/frontend/monitoring/components/refresh_button_spec.js +++ b/spec/frontend/monitoring/components/refresh_button_spec.js @@ -52,20 +52,6 @@ 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/requests/index_spec.js b/spec/frontend/monitoring/requests/index_spec.js index 6f9af911a9f..def4bfe9443 100644 --- a/spec/frontend/monitoring/requests/index_spec.js +++ b/spec/frontend/monitoring/requests/index_spec.js @@ -2,7 +2,10 @@ import MockAdapter from 'axios-mock-adapter'; import { backoffMockImplementation } from 'helpers/backoff_helper'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { + HTTP_STATUS_NO_CONTENT, + HTTP_STATUS_UNPROCESSABLE_ENTITY, +} from '~/lib/utils/http_status'; import { getDashboard, getPrometheusQueryData } from '~/monitoring/requests'; import { metricsDashboardResponse } from '../fixture_data'; @@ -37,8 +40,8 @@ describe('monitoring metrics_requests', () => { }); 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).replyOnce(HTTP_STATUS_NO_CONTENT); + mock.onGet(dashboardEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); mock.onGet(dashboardEndpoint).reply(statusCodes.OK, response); return getDashboard(dashboardEndpoint, params).then((data) => { @@ -81,8 +84,8 @@ describe('monitoring metrics_requests', () => { 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).replyOnce(HTTP_STATUS_NO_CONTENT); + mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); mock.onGet(prometheusEndpoint).reply(statusCodes.OK, response); // 3rd attempt return getPrometheusQueryData(prometheusEndpoint, params).then((data) => { @@ -116,8 +119,8 @@ describe('monitoring metrics_requests', () => { 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).replyOnce(HTTP_STATUS_NO_CONTENT); + mock.onGet(prometheusEndpoint).replyOnce(HTTP_STATUS_NO_CONTENT); mock.onGet(prometheusEndpoint).reply(500, { status: 'error', error: 'An error occurred', @@ -132,7 +135,7 @@ describe('monitoring metrics_requests', () => { it.each` code | reason ${statusCodes.BAD_REQUEST} | ${'Parameters are missing or incorrect'} - ${statusCodes.UNPROCESSABLE_ENTITY} | ${"Expression can't be executed"} + ${HTTP_STATUS_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, { diff --git a/spec/frontend/monitoring/store/actions_spec.js b/spec/frontend/monitoring/store/actions_spec.js index ca66768c3cc..93af6526c67 100644 --- a/spec/frontend/monitoring/store/actions_spec.js +++ b/spec/frontend/monitoring/store/actions_spec.js @@ -4,7 +4,10 @@ import testAction from 'helpers/vuex_action_helper'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import * as commonUtils from '~/lib/utils/common_utils'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { + HTTP_STATUS_CREATED, + HTTP_STATUS_UNPROCESSABLE_ENTITY, +} from '~/lib/utils/http_status'; import { ENVIRONMENT_AVAILABLE_STATE } from '~/monitoring/constants'; import getAnnotations from '~/monitoring/queries/get_annotations.query.graphql'; @@ -944,7 +947,7 @@ describe('Monitoring store actions', () => { }); it('Succesful POST request resolves', async () => { - mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, { + mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_CREATED, { dashboard: dashboardGitResponse[1], }); @@ -969,7 +972,7 @@ describe('Monitoring store actions', () => { commit_message: 'A new commit message', }); - mock.onPost(state.dashboardsEndpoint).reply(statusCodes.CREATED, { + mock.onPost(state.dashboardsEndpoint).reply(HTTP_STATUS_CREATED, { dashboard: mockCreatedDashboard, }); @@ -1133,7 +1136,7 @@ describe('Monitoring store actions', () => { mock .onPost(panelPreviewEndpoint, { panel_yaml: mockYmlContent }) - .reply(statusCodes.UNPROCESSABLE_ENTITY, { + .reply(HTTP_STATUS_UNPROCESSABLE_ENTITY, { message: mockErrorMsg, }); diff --git a/spec/frontend/monitoring/utils_spec.js b/spec/frontend/monitoring/utils_spec.js index 6c6c3d6b90f..348825c334a 100644 --- a/spec/frontend/monitoring/utils_spec.js +++ b/spec/frontend/monitoring/utils_spec.js @@ -435,6 +435,7 @@ describe('monitoring/utils', () => { describe('setCustomVariablesFromUrl', () => { beforeEach(() => { + window.history.pushState = jest.fn(); jest.spyOn(urlUtils, 'updateHistory'); }); diff --git a/spec/frontend/nav/components/new_nav_toggle_spec.js b/spec/frontend/nav/components/new_nav_toggle_spec.js new file mode 100644 index 00000000000..f09bdef8caa --- /dev/null +++ b/spec/frontend/nav/components/new_nav_toggle_spec.js @@ -0,0 +1,98 @@ +import { mount, createWrapper } from '@vue/test-utils'; +import MockAdapter from 'axios-mock-adapter'; +import { getByText as getByTextHelper } from '@testing-library/dom'; +import { GlToggle } from '@gitlab/ui'; +import axios from '~/lib/utils/axios_utils'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import NewNavToggle from '~/nav/components/new_nav_toggle.vue'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; +import { s__ } from '~/locale'; + +jest.mock('~/flash'); + +const TEST_ENDPONT = 'https://example.com/toggle'; + +describe('NewNavToggle', () => { + let wrapper; + + const findToggle = () => wrapper.findComponent(GlToggle); + + const createComponent = (propsData = { enabled: false }) => { + wrapper = mount(NewNavToggle, { + propsData: { + endpoint: TEST_ENDPONT, + ...propsData, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const getByText = (text, options) => + createWrapper(getByTextHelper(wrapper.element, text, options)); + + it('renders its title', () => { + createComponent(); + expect(getByText('Navigation redesign').exists()).toBe(true); + }); + + describe('when user preference is enabled', () => { + beforeEach(() => { + createComponent({ enabled: true }); + }); + + it('renders the toggle as enabled', () => { + expect(findToggle().props('value')).toBe(true); + }); + }); + + describe('when user preference is disabled', () => { + beforeEach(() => { + createComponent({ enabled: false }); + }); + + it('renders the toggle as disabled', () => { + expect(findToggle().props('value')).toBe(false); + }); + }); + + describe('changing the toggle', () => { + useMockLocationHelper(); + let mock; + + beforeEach(() => { + mock = new MockAdapter(axios); + createComponent(); + }); + + it('reloads the page on success', async () => { + mock.onPut(TEST_ENDPONT).reply(200); + findToggle().vm.$emit('change'); + await waitForPromises(); + + expect(window.location.reload).toHaveBeenCalled(); + }); + + it('shows an alert on error', async () => { + mock.onPut(TEST_ENDPONT).reply(500); + findToggle().vm.$emit('change'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith( + expect.objectContaining({ + message: s__( + 'NorthstarNavigation|Could not update the new navigation preference. Please try again later.', + ), + }), + ); + expect(window.location.reload).not.toHaveBeenCalled(); + }); + + afterEach(() => { + mock.restore(); + }); + }); +}); diff --git a/spec/frontend/notes/components/discussion_notes_spec.js b/spec/frontend/notes/components/discussion_notes_spec.js index a74d709ed3a..add2ed1ba8a 100644 --- a/spec/frontend/notes/components/discussion_notes_spec.js +++ b/spec/frontend/notes/components/discussion_notes_spec.js @@ -1,6 +1,5 @@ import { getByRole } from '@testing-library/dom'; import { shallowMount, mount } from '@vue/test-utils'; -import '~/behaviors/markdown/render_gfm'; import { nextTick } from 'vue'; import DiscussionNotes from '~/notes/components/discussion_notes.vue'; import NoteableNote from '~/notes/components/noteable_note.vue'; @@ -11,6 +10,8 @@ import PlaceholderSystemNote from '~/vue_shared/components/notes/placeholder_sys import SystemNote from '~/vue_shared/components/notes/system_note.vue'; import { noteableDataMock, discussionMock, notesDataMock } from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + const LINE_RANGE = {}; const DISCUSSION_WITH_LINE_RANGE = { ...discussionMock, diff --git a/spec/frontend/notes/components/noteable_discussion_spec.js b/spec/frontend/notes/components/noteable_discussion_spec.js index 2175849aeb9..a90d8bdde06 100644 --- a/spec/frontend/notes/components/noteable_discussion_spec.js +++ b/spec/frontend/notes/components/noteable_discussion_spec.js @@ -9,7 +9,6 @@ import ResolveWithIssueButton from '~/notes/components/discussion_resolve_with_i import NoteForm from '~/notes/components/note_form.vue'; import NoteableDiscussion from '~/notes/components/noteable_discussion.vue'; import createStore from '~/notes/stores'; -import '~/behaviors/markdown/render_gfm'; import { noteableDataMock, discussionMock, @@ -18,6 +17,8 @@ import { userDataMock, } from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + describe('noteable_discussion component', () => { let store; let wrapper; diff --git a/spec/frontend/notes/components/notes_app_spec.js b/spec/frontend/notes/components/notes_app_spec.js index 9051fcab97f..0c3d0da4f0f 100644 --- a/spec/frontend/notes/components/notes_app_spec.js +++ b/spec/frontend/notes/components/notes_app_spec.js @@ -2,23 +2,24 @@ import { mount, shallowMount } from '@vue/test-utils'; import AxiosMockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import { nextTick } from 'vue'; -import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import setWindowLocation from 'helpers/set_window_location_helper'; import waitForPromises from 'helpers/wait_for_promises'; import DraftNote from '~/batch_comments/components/draft_note.vue'; import batchComments from '~/batch_comments/stores/modules/batch_comments'; import axios from '~/lib/utils/axios_utils'; +import { getLocationHash } from '~/lib/utils/url_utility'; import * as urlUtility from '~/lib/utils/url_utility'; import CommentForm from '~/notes/components/comment_form.vue'; import NotesApp from '~/notes/components/notes_app.vue'; import NotesActivityHeader from '~/notes/components/notes_activity_header.vue'; import * as constants from '~/notes/constants'; import createStore from '~/notes/stores'; -import '~/behaviors/markdown/render_gfm'; -// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491) import OrderedLayout from '~/vue_shared/components/ordered_layout.vue'; +// TODO: use generated fixture (https://gitlab.com/gitlab-org/gitlab-foss/issues/62491) import * as mockData from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + const TYPE_COMMENT_FORM = 'comment-form'; const TYPE_NOTES_LIST = 'notes-list'; const TEST_NOTES_FILTER_VALUE = 1; @@ -26,7 +27,6 @@ const TEST_NOTES_FILTER_VALUE = 1; const propsData = { noteableData: mockData.noteableDataMock, notesData: mockData.notesDataMock, - userData: mockData.userDataMock, notesFilters: mockData.notesFilters, notesFilterValue: TEST_NOTES_FILTER_VALUE, }; @@ -37,6 +37,19 @@ describe('note_app', () => { let wrapper; let store; + const initStore = (notesData = propsData.notesData) => { + store.dispatch('setNotesData', notesData); + store.dispatch('setNoteableData', propsData.noteableData); + store.dispatch('setUserData', mockData.userDataMock); + store.dispatch('setTargetNoteHash', getLocationHash()); + // call after mounted hook + queueMicrotask(() => { + queueMicrotask(() => { + store.dispatch('fetchNotes'); + }); + }); + }; + const findCommentButton = () => wrapper.find('[data-testid="comment-button"]'); const getComponentOrder = () => { @@ -51,7 +64,9 @@ describe('note_app', () => { axiosMock = new AxiosMockAdapter(axios); store = createStore(); + mountComponent = ({ props = {} } = {}) => { + initStore(); return mount( { components: { @@ -60,6 +75,7 @@ describe('note_app', () => { template: `<div class="js-vue-notes-event"> <notes-app ref="notesApp" v-bind="$attrs" /> </div>`, + inheritAttrs: false, }, { propsData: { @@ -77,53 +93,13 @@ describe('note_app', () => { axiosMock.restore(); }); - describe('set data', () => { - beforeEach(() => { - setHTMLFixture('<div class="js-discussions-count"></div>'); - - axiosMock.onAny().reply(200, []); - wrapper = mountComponent(); - return waitForPromises(); - }); - - afterEach(() => { - resetHTMLFixture(); - }); - - it('should set notes data', () => { - expect(store.state.notesData).toEqual(mockData.notesDataMock); - }); - - it('should set issue data', () => { - expect(store.state.noteableData).toEqual(mockData.noteableDataMock); - }); - - it('should set user data', () => { - expect(store.state.userData).toEqual(mockData.userDataMock); - }); - - it('should fetch discussions', () => { - expect(store.state.discussions).toEqual([]); - }); - - it('updates discussions badge', () => { - expect(document.querySelector('.js-discussions-count').textContent).toEqual('0'); - }); - }); - describe('render', () => { beforeEach(() => { - setHTMLFixture('<div class="js-discussions-count"></div>'); - axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent(); return waitForPromises(); }); - afterEach(() => { - resetHTMLFixture(); - }); - it('should render list of notes', () => { const note = mockData.INDIVIDUAL_NOTE_RESPONSE_MAP.GET[ @@ -148,10 +124,6 @@ describe('note_app', () => { expect(findCommentButton().props('disabled')).toEqual(true); }); - it('updates discussions badge', () => { - expect(document.querySelector('.js-discussions-count').textContent).toEqual('2'); - }); - it('should render notes activity header', () => { expect(wrapper.findComponent(NotesActivityHeader).props()).toEqual({ notesFilterValue: TEST_NOTES_FILTER_VALUE, @@ -162,8 +134,6 @@ describe('note_app', () => { describe('render with comments disabled', () => { beforeEach(() => { - setHTMLFixture('<div class="js-discussions-count"></div>'); - axiosMock.onAny().reply(mockData.getIndividualNoteResponse); wrapper = mountComponent({ // why: In this integration test, previously we manually set store.state.commentsDisabled @@ -177,10 +147,6 @@ describe('note_app', () => { return waitForPromises(); }); - afterEach(() => { - resetHTMLFixture(); - }); - it('should not render form when commenting is disabled', () => { expect(wrapper.find('.js-main-target-form').exists()).toBe(false); }); @@ -192,8 +158,6 @@ describe('note_app', () => { describe('timeline view', () => { beforeEach(() => { - setHTMLFixture('<div class="js-discussions-count"></div>'); - axiosMock.onAny().reply(mockData.getIndividualNoteResponse); store.state.commentsDisabled = false; store.state.isTimelineEnabled = true; @@ -202,10 +166,6 @@ describe('note_app', () => { return waitForPromises(); }); - afterEach(() => { - resetHTMLFixture(); - }); - it('should not render comments form', () => { expect(wrapper.find('.js-main-target-form').exists()).toBe(false); }); @@ -213,14 +173,9 @@ describe('note_app', () => { describe('while fetching data', () => { beforeEach(async () => { - setHTMLFixture('<div class="js-discussions-count"></div>'); wrapper = mountComponent(); }); - afterEach(() => { - return waitForPromises().then(() => resetHTMLFixture()); - }); - it('renders skeleton notes', () => { expect(wrapper.find('.gl-skeleton-loader-default-container').exists()).toBe(true); }); @@ -231,10 +186,6 @@ describe('note_app', () => { 'Write a comment or drag your files here…', ); }); - - it('should not update discussions badge (it should be blank)', () => { - expect(document.querySelector('.js-discussions-count').textContent).toEqual(''); - }); }); describe('update note', () => { @@ -468,7 +419,9 @@ describe('note_app', () => { describe('fetching discussions', () => { describe('when note anchor is not present', () => { it('does not include extra query params', async () => { - wrapper = shallowMount(NotesApp, { propsData, store: createStore() }); + store = createStore(); + initStore(); + wrapper = shallowMount(NotesApp, { propsData, store }); await waitForPromises(); expect(axiosMock.history.get[0].params).toEqual({ per_page: 20 }); @@ -476,17 +429,16 @@ describe('note_app', () => { }); describe('when note anchor is present', () => { - const mountWithNotesFilter = (notesFilter) => - shallowMount(NotesApp, { - propsData: { - ...propsData, - notesData: { - ...propsData.notesData, - notesFilter, - }, - }, + const mountWithNotesFilter = (notesFilter) => { + initStore({ + ...propsData.notesData, + notesFilter, + }); + return shallowMount(NotesApp, { + propsData, store: createStore(), }); + }; beforeEach(() => { setWindowLocation('#note_1'); diff --git a/spec/frontend/notes/deprecated_notes_spec.js b/spec/frontend/notes/deprecated_notes_spec.js index d5e2a189afe..f52c3e28691 100644 --- a/spec/frontend/notes/deprecated_notes_spec.js +++ b/spec/frontend/notes/deprecated_notes_spec.js @@ -4,7 +4,6 @@ import MockAdapter from 'axios-mock-adapter'; import $ from 'jquery'; import '~/behaviors/markdown/render_gfm'; import { loadHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; -import { createSpyObj } from 'helpers/jest_helpers'; import { TEST_HOST } from 'helpers/test_constants'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; @@ -254,16 +253,20 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { note: 'heya', html: '<div>heya</div>', }; - $notesList = createSpyObj('$notesList', ['find', 'append']); - - notes = createSpyObj('notes', [ - 'setupNewNote', - 'refresh', - 'collapseLongCommitList', - 'updateNotesCount', - 'putConflictEditWarningInPlace', - ]); - notes.taskList = createSpyObj('tasklist', ['init']); + $notesList = { + find: jest.fn(), + append: jest.fn(), + }; + notes = { + setupNewNote: jest.fn(), + refresh: jest.fn(), + collapseLongCommitList: jest.fn(), + updateNotesCount: jest.fn(), + putConflictEditWarningInPlace: jest.fn(), + }; + notes.taskList = { + init: jest.fn(), + }; notes.note_ids = []; notes.updatedNotesTrackingMap = {}; @@ -383,11 +386,21 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { discussion_resolvable: false, diff_discussion_html: false, }; - $form = createSpyObj('$form', ['closest', 'find']); + $form = { + closest: jest.fn(), + find: jest.fn(), + }; $form.length = 1; - row = createSpyObj('row', ['prevAll', 'first', 'find']); + row = { + prevAll: jest.fn(), + first: jest.fn(), + find: jest.fn(), + }; - notes = createSpyObj('notes', ['isParallelView', 'updateNotesCount']); + notes = { + isParallelView: jest.fn(), + updateNotesCount: jest.fn(), + }; notes.note_ids = []; jest.spyOn(Notes, 'isNewNote'); @@ -403,7 +416,9 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { let body; beforeEach(() => { - body = createSpyObj('body', ['attr']); + body = { + attr: jest.fn(), + }; discussionContainer = { length: 0 }; $form.closest.mockReturnValueOnce(row).mockReturnValue($form); @@ -462,7 +477,9 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { noteHTML = '<div></div>'; - $notesList = createSpyObj('$notesList', ['append']); + $notesList = { + append: jest.fn(), + }; $resultantNote = Notes.animateAppendNote(noteHTML, $notesList); }); @@ -483,7 +500,9 @@ describe.skip('Old Notes (~/deprecated_notes.js)', () => { beforeEach(() => { noteHTML = '<div></div>'; - $note = createSpyObj('$note', ['replaceWith']); + $note = { + replaceWith: jest.fn(), + }; $updatedNote = Notes.animateUpdateNote(noteHTML, $note); }); diff --git a/spec/frontend/notes/stores/actions_spec.js b/spec/frontend/notes/stores/actions_spec.js index 989dd74b6d0..dce2e5d370d 100644 --- a/spec/frontend/notes/stores/actions_spec.js +++ b/spec/frontend/notes/stores/actions_spec.js @@ -3,7 +3,7 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import testAction from 'helpers/vuex_action_helper'; import { TEST_HOST } from 'spec/test_constants'; import Api from '~/api'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import toast from '~/vue_shared/plugins/global_toast'; import { EVENT_ISSUABLE_VUE_APP_CHANGE } from '~/issuable/constants'; import axios from '~/lib/utils/axios_utils'; @@ -13,8 +13,8 @@ import * as actions from '~/notes/stores/actions'; import * as mutationTypes from '~/notes/stores/mutation_types'; import mutations from '~/notes/stores/mutations'; import * as utils from '~/notes/stores/utils'; -import updateIssueLockMutation from '~/sidebar/components/lock/mutations/update_issue_lock.mutation.graphql'; -import updateMergeRequestLockMutation from '~/sidebar/components/lock/mutations/update_merge_request_lock.mutation.graphql'; +import updateIssueLockMutation from '~/sidebar/queries/update_issue_lock.mutation.graphql'; +import updateMergeRequestLockMutation from '~/sidebar/queries/update_merge_request_lock.mutation.graphql'; import promoteTimelineEvent from '~/notes/graphql/promote_timeline_event.mutation.graphql'; import mrWidgetEventHub from '~/vue_merge_request_widget/event_hub'; import notesEventHub from '~/notes/event_hub'; @@ -30,16 +30,12 @@ import { } from '../mock_data'; const TEST_ERROR_MESSAGE = 'Test error message'; -const mockFlashClose = jest.fn(); -jest.mock('~/flash', () => { - const flash = jest.fn().mockImplementation(() => { - return { - close: mockFlashClose, - }; - }); - - return flash; -}); +const mockAlertDismiss = jest.fn(); +jest.mock('~/flash', () => ({ + createAlert: jest.fn().mockImplementation(() => ({ + dismiss: mockAlertDismiss, + })), +})); jest.mock('~/vue_shared/plugins/global_toast'); @@ -331,13 +327,13 @@ describe('Actions Notes Store', () => { await startPolling(); expect(axiosMock.history.get).toHaveLength(1); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); await advanceXMoreIntervals(1); expect(axiosMock.history.get).toHaveLength(2); - expect(createFlash).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('resets the failure counter on success', async () => { @@ -358,14 +354,13 @@ describe('Actions Notes Store', () => { await advanceXMoreIntervals(1); // Failure #2 // That was the first failure AFTER a success, so we should NOT see the error displayed - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); // Now we'll allow another failure await advanceXMoreIntervals(1); // Failure #3 // Since this is the second failure in a row, the error should happen - expect(createFlash).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); }); it('hides the error display if it exists on success', async () => { @@ -375,16 +370,14 @@ describe('Actions Notes Store', () => { await advanceXMoreIntervals(2); // After two errors, the error should be displayed - expect(createFlash).toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledTimes(1); axiosMock.reset(); successMock(); await advanceXMoreIntervals(1); - expect(mockFlashClose).toHaveBeenCalled(); - expect(mockFlashClose).toHaveBeenCalledTimes(1); + expect(mockAlertDismiss).toHaveBeenCalledTimes(1); }); }); }); @@ -869,7 +862,7 @@ describe('Actions Notes Store', () => { payload, ), ).rejects.toEqual(error); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -885,8 +878,8 @@ describe('Actions Notes Store', () => { }, { ...payload, flashContainer }, ); - expect(resp.hasFlash).toBe(true); - expect(createFlash).toHaveBeenCalledWith({ + expect(resp.hasAlert).toBe(true); + expect(createAlert).toHaveBeenCalledWith({ message: 'Your comment could not be submitted because something went wrong', parent: flashContainer, }); @@ -905,7 +898,7 @@ describe('Actions Notes Store', () => { payload, ); expect(data).toBe(res); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); }); @@ -943,7 +936,7 @@ describe('Actions Notes Store', () => { ['resolveDiscussion', { discussionId }], ['restartPolling'], ]); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -958,7 +951,7 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: TEST_ERROR_MESSAGE, parent: flashContainer, }); @@ -976,7 +969,7 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while applying the suggestion. Please try again.', parent: flashContainer, }); @@ -987,7 +980,7 @@ describe('Actions Notes Store', () => { dispatch.mockReturnValue(Promise.reject()); return testSubmitSuggestion(() => { - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); }); @@ -1029,7 +1022,7 @@ describe('Actions Notes Store', () => { ['restartPolling'], ]); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); @@ -1047,7 +1040,7 @@ describe('Actions Notes Store', () => { ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: TEST_ERROR_MESSAGE, parent: flashContainer, }); @@ -1068,7 +1061,7 @@ describe('Actions Notes Store', () => { ]); expect(dispatch.mock.calls).toEqual([['stopPolling'], ['restartPolling']]); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledWith({ message: 'Something went wrong while applying the batch of suggestions. Please try again.', parent: flashContainer, @@ -1088,7 +1081,7 @@ describe('Actions Notes Store', () => { [mutationTypes.SET_RESOLVING_DISCUSSION, false], ]); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); }); }); @@ -1234,7 +1227,7 @@ describe('Actions Notes Store', () => { ), ).rejects.toEqual(new Error()); - expect(createFlash).toHaveBeenCalled(); + expect(createAlert).toHaveBeenCalled(); }); }); }); @@ -1414,7 +1407,7 @@ describe('Actions Notes Store', () => { return actions .promoteCommentToTimelineEvent({ commit: commitSpy }, actionArgs) .then(() => { - expect(createFlash).toHaveBeenCalledWith(expectedAlertArgs); + expect(createAlert).toHaveBeenCalledWith(expectedAlertArgs); expect(commitSpy).toHaveBeenCalledWith( mutationTypes.SET_PROMOTE_COMMENT_TO_TIMELINE_PROGRESS, false, diff --git a/spec/frontend/observability/observability_app_spec.js b/spec/frontend/observability/observability_app_spec.js index f0b318e69ec..248b0a2057c 100644 --- a/spec/frontend/observability/observability_app_spec.js +++ b/spec/frontend/observability/observability_app_spec.js @@ -1,5 +1,16 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ObservabilityApp from '~/observability/components/observability_app.vue'; +import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; + +import { + MESSAGE_EVENT_TYPE, + OBSERVABILITY_ROUTES, + SKELETON_VARIANT, +} from '~/observability/constants'; + +import { darkModeEnabled } from '~/lib/utils/color_utils'; + +jest.mock('~/lib/utils/color_utils'); describe('Observability root app', () => { let wrapper; @@ -12,6 +23,8 @@ describe('Observability root app', () => { query: { otherQuery: 100 }, }; + const mockHandleSkeleton = jest.fn(); + const findIframe = () => wrapper.findByTestId('observability-ui-iframe'); const TEST_IFRAME_SRC = 'https://observe.gitlab.com/9970/?groupId=14485840'; @@ -21,6 +34,9 @@ describe('Observability root app', () => { propsData: { observabilityIframeSrc: TEST_IFRAME_SRC, }, + stubs: { + 'observability-skeleton': ObservabilitySkeleton, + }, mocks: { $router, $route: route, @@ -28,46 +44,156 @@ describe('Observability root app', () => { }); }; + const dispatchMessageEvent = (message) => + window.dispatchEvent(new MessageEvent('message', message)); + afterEach(() => { wrapper.destroy(); }); - it('should render an iframe with observabilityIframeSrc as src', () => { - mountComponent(); - const iframe = findIframe(); - expect(iframe.exists()).toBe(true); - expect(iframe.attributes('src')).toBe(TEST_IFRAME_SRC); + describe('iframe src', () => { + const TEST_USERNAME = 'test-user'; + + beforeAll(() => { + gon.current_username = TEST_USERNAME; + }); + + it('should render an iframe with observabilityIframeSrc, decorated with light theme and username', () => { + darkModeEnabled.mockReturnValueOnce(false); + mountComponent(); + const iframe = findIframe(); + + expect(iframe.exists()).toBe(true); + expect(iframe.attributes('src')).toBe( + `${TEST_IFRAME_SRC}&theme=light&username=${TEST_USERNAME}`, + ); + }); + + it('should render an iframe with observabilityIframeSrc decorated with dark theme and username', () => { + darkModeEnabled.mockReturnValueOnce(true); + mountComponent(); + const iframe = findIframe(); + + expect(iframe.exists()).toBe(true); + expect(iframe.attributes('src')).toBe( + `${TEST_IFRAME_SRC}&theme=dark&username=${TEST_USERNAME}`, + ); + }); }); - it('should not call replace method from vue router if message event does not have url', () => { - mountComponent(); - wrapper.vm.messageHandler({ data: 'some other data' }); - expect(replace).not.toHaveBeenCalled(); + describe('iframe sandbox', () => { + it('should render an iframe with sandbox attributes', () => { + mountComponent(); + const iframe = findIframe(); + + expect(iframe.exists()).toBe(true); + expect(iframe.attributes('sandbox')).toBe('allow-same-origin allow-forms allow-scripts'); + }); }); - it.each` - condition | origin | observability_path | url - ${'message origin is different from iframe source origin'} | ${'https://example.com'} | ${'/'} | ${'/explore'} - ${'path is same as before (observability_path)'} | ${'https://observe.gitlab.com'} | ${'/foo?bar=test'} | ${'/foo?bar=test'} - `( - 'should not call replace method from vue router if $condition', - async ({ origin, observability_path, url }) => { - mountComponent({ ...$route, query: { observability_path } }); - wrapper.vm.messageHandler({ data: { url }, origin }); + describe('on GOUI_ROUTE_UPDATE', () => { + it('should not call replace method from vue router if message event does not have url', () => { + mountComponent(); + dispatchMessageEvent({ + type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, + payload: { data: 'some other data' }, + }); expect(replace).not.toHaveBeenCalled(); - }, - ); - - it('should call replace method from vue router on messageHandle call', () => { - mountComponent(); - wrapper.vm.messageHandler({ data: { url: '/explore' }, origin: 'https://observe.gitlab.com' }); - expect(replace).toHaveBeenCalled(); - expect(replace).toHaveBeenCalledWith({ - name: 'https://gitlab.com/gitlab-org/', - query: { - otherQuery: 100, - observability_path: '/explore', + }); + + it.each` + condition | origin | observability_path | url + ${'message origin is different from iframe source origin'} | ${'https://example.com'} | ${'/'} | ${'/explore'} + ${'path is same as before (observability_path)'} | ${'https://observe.gitlab.com'} | ${'/foo?bar=test'} | ${'/foo?bar=test'} + `( + 'should not call replace method from vue router if $condition', + async ({ origin, observability_path, url }) => { + mountComponent({ ...$route, query: { observability_path } }); + dispatchMessageEvent({ + data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url } }, + origin, + }); + expect(replace).not.toHaveBeenCalled(); }, + ); + + it('should call replace method from vue router on message event callback', () => { + mountComponent(); + + dispatchMessageEvent({ + data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url: '/explore' } }, + origin: 'https://observe.gitlab.com', + }); + + expect(replace).toHaveBeenCalled(); + expect(replace).toHaveBeenCalledWith({ + name: 'https://gitlab.com/gitlab-org/', + query: { + otherQuery: 100, + observability_path: '/explore', + }, + }); + }); + }); + + describe('on GOUI_LOADED', () => { + beforeEach(() => { + mountComponent(); + wrapper.vm.$refs.iframeSkeleton.handleSkeleton = mockHandleSkeleton; + }); + it('should call handleSkeleton method', () => { + dispatchMessageEvent({ + data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, + origin: 'https://observe.gitlab.com', + }); + expect(mockHandleSkeleton).toHaveBeenCalled(); + }); + + it('should not call handleSkeleton method if origin is different', () => { + dispatchMessageEvent({ + data: { type: MESSAGE_EVENT_TYPE.GOUI_LOADED }, + origin: 'https://example.com', + }); + expect(mockHandleSkeleton).not.toHaveBeenCalled(); + }); + + it('should not call handleSkeleton method if event type is different', () => { + dispatchMessageEvent({ + data: { type: 'UNKNOWN_EVENT' }, + origin: 'https://observe.gitlab.com', + }); + expect(mockHandleSkeleton).not.toHaveBeenCalled(); + }); + }); + + describe('skeleton variant', () => { + it.each` + pathDescription | path | variant + ${'dashboards'} | ${OBSERVABILITY_ROUTES.DASHBOARDS} | ${SKELETON_VARIANT.DASHBOARDS} + ${'explore'} | ${OBSERVABILITY_ROUTES.EXPLORE} | ${SKELETON_VARIANT.EXPLORE} + ${'manage dashboards'} | ${OBSERVABILITY_ROUTES.MANAGE} | ${SKELETON_VARIANT.MANAGE} + ${'any other'} | ${'unknown/route'} | ${SKELETON_VARIANT.DASHBOARDS} + `('renders the $variant skeleton variant for $pathDescription path', ({ path, variant }) => { + mountComponent({ ...$route, path }); + const props = wrapper.findComponent(ObservabilitySkeleton).props(); + + expect(props.variant).toBe(variant); + }); + }); + + describe('on observability ui unmount', () => { + it('should remove message event and should not call replace method from vue router', () => { + mountComponent(); + wrapper.destroy(); + + // testing event cleanup logic, should not call on messege event after component is destroyed + + dispatchMessageEvent({ + data: { type: MESSAGE_EVENT_TYPE.GOUI_ROUTE_UPDATE, payload: { url: '/explore' } }, + origin: 'https://observe.gitlab.com', + }); + + expect(replace).not.toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/observability/skeleton_spec.js b/spec/frontend/observability/skeleton_spec.js new file mode 100644 index 00000000000..5637c0e6d70 --- /dev/null +++ b/spec/frontend/observability/skeleton_spec.js @@ -0,0 +1,96 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ObservabilitySkeleton from '~/observability/components/skeleton/index.vue'; +import DashboardsSkeleton from '~/observability/components/skeleton/dashboards.vue'; +import ExploreSkeleton from '~/observability/components/skeleton/explore.vue'; +import ManageSkeleton from '~/observability/components/skeleton/manage.vue'; + +import { SKELETON_VARIANT } from '~/observability/constants'; + +describe('ObservabilitySkeleton component', () => { + let wrapper; + + const mountComponent = ({ ...props } = {}) => { + wrapper = shallowMountExtended(ObservabilitySkeleton, { + propsData: props, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('on mount', () => { + beforeEach(() => { + jest.spyOn(global, 'setTimeout'); + mountComponent(); + }); + + it('should call setTimeout on mount and show ObservabilitySkeleton if Observability UI is not loaded yet', () => { + jest.runAllTimers(); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); + expect(wrapper.vm.loading).toBe(true); + expect(wrapper.vm.timerId).not.toBeNull(); + }); + + it('should call setTimeout on mount and dont show ObservabilitySkeleton if Observability UI is loaded', () => { + wrapper.vm.loading = false; + jest.runAllTimers(); + + expect(setTimeout).toHaveBeenCalledWith(expect.any(Function), 500); + expect(wrapper.vm.loading).toBe(false); + expect(wrapper.vm.timerId).not.toBeNull(); + }); + }); + + describe('handleSkeleton', () => { + it('will not show the skeleton if Observability UI is loaded before', () => { + jest.spyOn(global, 'clearTimeout'); + mountComponent(); + wrapper.vm.handleSkeleton(); + expect(clearTimeout).toHaveBeenCalledWith(wrapper.vm.timerId); + }); + + it('will hide skeleton gracefully after 400ms if skeleton was present on screen before Observability UI', () => { + jest.spyOn(global, 'setTimeout'); + mountComponent(); + jest.runAllTimers(); + wrapper.vm.handleSkeleton(); + jest.runAllTimers(); + + expect(setTimeout).toHaveBeenCalledWith(wrapper.vm.hideSkeleton, 400); + expect(wrapper.vm.loading).toBe(false); + }); + }); + + describe('skeleton variant', () => { + it.each` + skeletonType | condition | variant + ${'dashboards'} | ${'variant is dashboards'} | ${SKELETON_VARIANT.DASHBOARDS} + ${'explore'} | ${'variant is explore'} | ${SKELETON_VARIANT.EXPLORE} + ${'manage'} | ${'variant is manage'} | ${SKELETON_VARIANT.MANAGE} + ${'default'} | ${'variant is not manage, dashboards or explore'} | ${'unknown'} + `('should render $skeletonType skeleton if $condition', async ({ skeletonType, variant }) => { + mountComponent({ variant }); + const showsDefaultSkeleton = ![ + SKELETON_VARIANT.DASHBOARDS, + SKELETON_VARIANT.EXPLORE, + SKELETON_VARIANT.MANAGE, + ].includes(variant); + expect(wrapper.findComponent(DashboardsSkeleton).exists()).toBe( + skeletonType === SKELETON_VARIANT.DASHBOARDS, + ); + expect(wrapper.findComponent(ExploreSkeleton).exists()).toBe( + skeletonType === SKELETON_VARIANT.EXPLORE, + ); + expect(wrapper.findComponent(ManageSkeleton).exists()).toBe( + skeletonType === SKELETON_VARIANT.MANAGE, + ); + + expect(wrapper.findComponent(GlSkeletonLoader).exists()).toBe(showsDefaultSkeleton); + }); + }); +}); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js index 4a026f35822..d45b993b5a2 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js @@ -6,7 +6,6 @@ import { DELETE_TAG_ERROR_MESSAGE, DELETE_TAGS_SUCCESS_MESSAGE, DELETE_TAGS_ERROR_MESSAGE, - DETAILS_IMPORTING_ERROR_MESSAGE, ADMIN_GARBAGE_COLLECTION_TIP, } from '~/packages_and_registries/container_registry/explorer/constants'; @@ -77,7 +76,6 @@ describe('Delete alert', () => { }); }); }); - describe('error states', () => { describe.each` deleteAlertType | message @@ -107,25 +105,6 @@ describe('Delete alert', () => { }); }); - describe('importing repository error state', () => { - beforeEach(() => { - mountComponent({ - deleteAlertType: 'danger_importing', - containerRegistryImportingHelpPagePath: 'https://foobar', - }); - }); - - it('alert exist and text is appropriate', () => { - expect(findAlert().text()).toMatchInterpolatedText(DETAILS_IMPORTING_ERROR_MESSAGE); - }); - - it('alert body contains link', () => { - const alertLink = findLink(); - expect(alertLink.exists()).toBe(true); - expect(alertLink.attributes('href')).toBe('https://foobar'); - }); - }); - describe('dismissing alert', () => { it('GlAlert dismiss event triggers a change event', () => { mountComponent({ deleteAlertType: 'success_tags' }); diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js index b163557618e..1017ff06a25 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js @@ -18,7 +18,7 @@ import { NO_TAGS_MATCHING_FILTERS_TITLE, NO_TAGS_MATCHING_FILTERS_DESCRIPTION, } from '~/packages_and_registries/container_registry/explorer/constants/index'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data'; describe('Tags List', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js index b11048cd7a2..e5b99f15e8c 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js @@ -249,15 +249,6 @@ export const graphQLDeleteImageRepositoryTagsMock = { }, }; -export const graphQLDeleteImageRepositoryTagImportingErrorMock = { - data: { - destroyContainerRepositoryTags: { - errors: ['repository importing'], - __typename: 'DestroyContainerRepositoryTagsPayload', - }, - }, -}; - export const dockerCommands = { dockerBuildCommand: 'foofoo', dockerPushCommand: 'barbar', diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js index 310398b01cf..26f0e506829 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js @@ -18,7 +18,6 @@ import { UNFINISHED_STATUS, DELETE_SCHEDULED, ALERT_DANGER_IMAGE, - ALERT_DANGER_IMPORTING, MISSING_OR_DELETED_IMAGE_BREADCRUMB, MISSING_OR_DELETED_IMAGE_TITLE, MISSING_OR_DELETED_IMAGE_MESSAGE, @@ -34,7 +33,6 @@ import Tracking from '~/tracking'; import { graphQLImageDetailsMock, graphQLDeleteImageRepositoryTagsMock, - graphQLDeleteImageRepositoryTagImportingErrorMock, graphQLProjectImageRepositoriesDetailsMock, containerRepositoryMock, graphQLEmptyImageDetailsMock, @@ -341,7 +339,6 @@ describe('Details Page', () => { const config = { isAdmin: true, garbageCollectionHelpPagePath: 'baz', - containerRegistryImportingHelpPagePath: 'https://foobar', }; const deleteAlertType = 'success_tag'; @@ -366,38 +363,6 @@ describe('Details Page', () => { expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType }); }); - - describe('importing repository error', () => { - let mutationResolver; - let tagsResolver; - let detailsResolver; - - beforeEach(async () => { - mutationResolver = jest - .fn() - .mockResolvedValue(graphQLDeleteImageRepositoryTagImportingErrorMock); - tagsResolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock(imageTagsMock())); - detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock); - - mountComponent({ mutationResolver, tagsResolver, detailsResolver }); - await waitForApolloRequestRender(); - }); - - it('displays the proper alert', async () => { - findTagsList().vm.$emit('delete', [cleanTags[0]]); - await nextTick(); - - findDeleteModal().vm.$emit('confirmDelete'); - await waitForPromises(); - - expect(tagsResolver).toHaveBeenCalled(); - expect(detailsResolver).toHaveBeenCalled(); - - const deleteAlert = findDeleteAlert(); - expect(deleteAlert.exists()).toBe(true); - expect(deleteAlert.props('deleteAlertType')).toBe(ALERT_DANGER_IMPORTING); - }); - }); }); describe('Partial Cleanup Alert', () => { diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js index 79403d29d18..1e514d85e82 100644 --- a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js +++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js @@ -6,7 +6,6 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue'; import CliCommands from '~/packages_and_registries/shared/components/cli_commands.vue'; import GroupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue'; @@ -23,6 +22,7 @@ import getContainerRepositoriesDetails from '~/packages_and_registries/container import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue'; import Tracking from '~/tracking'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import TitleArea from '~/vue_shared/components/registry/title_area.vue'; import { $toast } from 'jest/packages_and_registries/shared/mocks'; diff --git a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js index fb50d623543..329cc15df97 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/app_spec.js @@ -14,7 +14,6 @@ import VueApollo from 'vue-apollo'; import MockAdapter from 'axios-mock-adapter'; import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; -import { stripTypenames } from 'helpers/graphql_helpers'; import waitForPromises from 'helpers/wait_for_promises'; import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/dependency_proxy/constants'; import axios from '~/lib/utils/axios_utils'; @@ -190,7 +189,7 @@ describe('DependencyProxyApp', () => { it('shows list', () => { expect(findManifestList().props()).toMatchObject({ manifests: proxyManifests(), - pagination: stripTypenames(pagination()), + pagination: pagination(), }); }); diff --git a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js index 9e4c747a1bd..2f415bfd6f9 100644 --- a/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js +++ b/spec/frontend/packages_and_registries/dependency_proxy/components/manifest_list_spec.js @@ -1,5 +1,4 @@ import { GlKeysetPagination } from '@gitlab/ui'; -import { stripTypenames } from 'helpers/graphql_helpers'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import ManifestRow from '~/packages_and_registries/dependency_proxy/components/manifest_row.vue'; @@ -14,7 +13,7 @@ describe('Manifests List', () => { const defaultProps = { manifests: proxyManifests(), - pagination: stripTypenames(pagination()), + pagination: pagination(), }; const createComponent = (propsData = defaultProps) => { @@ -60,9 +59,8 @@ describe('Manifests List', () => { it('has the correct props', () => { createComponent(); - expect(findPagination().props()).toMatchObject({ - ...defaultProps.pagination, - }); + const { __typename, ...paginationProps } = defaultProps.pagination; + expect(findPagination().props()).toMatchObject(paginationProps); }); it('emits the next-page event', () => { diff --git a/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js index 8fd50bea280..69765d31674 100644 --- a/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/harbor_registry/pages/details_spec.js @@ -8,7 +8,7 @@ import ArtifactsList from '~/packages_and_registries/harbor_registry/components/ import waitForPromises from 'helpers/wait_for_promises'; import DetailsHeader from '~/packages_and_registries/harbor_registry/components/details/details_header.vue'; import PersistedSearch from '~/packages_and_registries/shared/components/persisted_search.vue'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPERATORS_IS } from '~/vue_shared/components/filtered_search_bar/constants'; import { NAME_SORT_FIELD, TOKEN_TYPE_TAG_NAME, @@ -137,7 +137,7 @@ describe('Harbor Details Page', () => { title: s__('HarborRegistry|Tag'), unique: true, token: GlFilteredSearchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }, ], }); diff --git a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js index dff95364d7d..d237023d0cd 100644 --- a/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js +++ b/spec/frontend/packages_and_registries/infrastructure_registry/components/list/components/packages_list_app_spec.js @@ -7,13 +7,11 @@ import { createAlert, VARIANT_INFO } from '~/flash'; import * as commonUtils from '~/lib/utils/common_utils'; import PackageListApp from '~/packages_and_registries/infrastructure_registry/list/components/packages_list_app.vue'; import { DELETE_PACKAGE_SUCCESS_MESSAGE } from '~/packages_and_registries/infrastructure_registry/list/constants'; -import { - SHOW_DELETE_SUCCESS_ALERT, - FILTERED_SEARCH_TERM, -} from '~/packages_and_registries/shared/constants'; +import { SHOW_DELETE_SUCCESS_ALERT } from '~/packages_and_registries/shared/constants'; import * as packageUtils from '~/packages_and_registries/shared/utils'; import InfrastructureSearch from '~/packages_and_registries/infrastructure_registry/list/components/infrastructure_search.vue'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; jest.mock('~/lib/utils/common_utils'); jest.mock('~/flash'); diff --git a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap index 92c2cd90568..c4020eeb75f 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap +++ b/spec/frontend/packages_and_registries/package_registry/components/details/__snapshots__/pypi_installation_spec.js.snap @@ -13,7 +13,7 @@ exports[`PypiInstallation renders all the messages 1`] = ` <div> <div - class="dropdown b-dropdown gl-new-dropdown btn-group" + class="dropdown b-dropdown gl-dropdown btn-group" id="__BVID__27" lazy="" > @@ -30,7 +30,7 @@ exports[`PypiInstallation renders all the messages 1`] = ` <!----> <span - class="gl-new-dropdown-button-text" + class="gl-dropdown-button-text" > Show PyPi commands </span> diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js index 913b4f5926f..bb04701a8b7 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/package_list_row_spec.js @@ -1,4 +1,4 @@ -import { GlFormCheckbox, GlSprintf } from '@gitlab/ui'; +import { GlFormCheckbox, GlSprintf, GlTruncate } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueRouter from 'vue-router'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; @@ -15,7 +15,13 @@ import TimeagoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import { PACKAGE_ERROR_STATUS } from '~/packages_and_registries/package_registry/constants'; import ListItem from '~/vue_shared/components/registry/list_item.vue'; -import { packageData, packagePipelines, packageProject, packageTags } from '../../mock_data'; +import { + linksData, + packageData, + packagePipelines, + packageProject, + packageTags, +} from '../../mock_data'; Vue.use(VueRouter); @@ -26,9 +32,9 @@ describe('packages_list_row', () => { isGroupPage: false, }; - const packageWithoutTags = { ...packageData(), project: packageProject() }; + const packageWithoutTags = { ...packageData(), project: packageProject(), ...linksData }; const packageWithTags = { ...packageWithoutTags, tags: { nodes: packageTags() } }; - const packageCannotDestroy = { ...packageData(), canDestroy: false }; + const packageCannotDestroy = { ...packageData(), ...linksData, canDestroy: false }; const findPackageTags = () => wrapper.findComponent(PackageTags); const findPackagePath = () => wrapper.findComponent(PackagePath); @@ -41,6 +47,7 @@ describe('packages_list_row', () => { const findCreatedDateText = () => wrapper.findByTestId('created-date'); const findTimeAgoTooltip = () => wrapper.findComponent(TimeagoTooltip); const findBulkDeleteAction = () => wrapper.findComponent(GlFormCheckbox); + const findPackageName = () => wrapper.findComponent(GlTruncate); const mountComponent = ({ packageEntity = packageWithoutTags, @@ -81,6 +88,22 @@ describe('packages_list_row', () => { }); }); + it('does not have a link to navigate to the details page', () => { + mountComponent({ + packageEntity: { + ...packageWithoutTags, + _links: { + webPath: null, + }, + }, + }); + + expect(findPackageLink().exists()).toBe(false); + expect(findPackageName().props()).toMatchObject({ + text: '@gitlab-org/package-15', + }); + }); + describe('tags', () => { it('renders package tags when a package has tags', () => { mountComponent({ packageEntity: packageWithTags }); diff --git a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js index 19505618ff7..a884959ab62 100644 --- a/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/components/list/packages_search_spec.js @@ -10,6 +10,7 @@ import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; import { LIST_KEY_CREATED_AT } from '~/packages_and_registries/package_registry/constants'; import { getQueryParams, extractFilterAndSorting } from '~/packages_and_registries/shared/utils'; +import { TOKEN_TYPE_TYPE } from '~/vue_shared/components/filtered_search_bar/constants'; jest.mock('~/packages_and_registries/shared/utils'); @@ -92,7 +93,11 @@ describe('Package Search', () => { expect(findRegistrySearch().props()).toMatchObject({ tokens: expect.arrayContaining([ - expect.objectContaining({ token: PackageTypeToken, type: 'type', icon: 'package' }), + expect.objectContaining({ + token: PackageTypeToken, + type: TOKEN_TYPE_TYPE, + icon: 'package', + }), ]), sortableFields: sortableFields(isGroupPage), }); diff --git a/spec/frontend/packages_and_registries/package_registry/mock_data.js b/spec/frontend/packages_and_registries/package_registry/mock_data.js index f36c5923532..9e9e08bc196 100644 --- a/spec/frontend/packages_and_registries/package_registry/mock_data.js +++ b/spec/frontend/packages_and_registries/package_registry/mock_data.js @@ -118,6 +118,13 @@ export const packageVersions = () => [ }, ]; +export const linksData = { + _links: { + webPath: '/gitlab-org/package-15', + __typeName: 'PackageLinks', + }, +}; + export const packageData = (extend) => ({ __typename: 'Package', id: 'gid://gitlab/Packages::Package/111', @@ -232,6 +239,7 @@ export const packageDetailsQuery = (extendPackage) => ({ __typename: 'PackageFileConnection', }, versions: { + count: packageVersions().length, nodes: packageVersions(), pageInfo: { hasNextPage: true, @@ -376,6 +384,7 @@ export const packagesListQuery = ({ type = 'group', extend = {}, extendPaginatio nodes: [ { ...packageData(), + ...linksData, project: packageProject(), tags: { nodes: packageTags() }, pipelines: { @@ -387,6 +396,7 @@ export const packagesListQuery = ({ type = 'group', extend = {}, extendPaginatio project: packageProject(), tags: { nodes: [] }, pipelines: { nodes: [] }, + ...linksData, }, ], pageInfo: pagination(extendPagination), diff --git a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js index f942a334f40..eb3b999c1ca 100644 --- a/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js +++ b/spec/frontend/packages_and_registries/package_registry/pages/details_spec.js @@ -1,4 +1,4 @@ -import { GlEmptyState, GlBadge, GlTabs, GlTab, GlSprintf } from '@gitlab/ui'; +import { GlEmptyState, GlTabs, GlTab, GlSprintf } from '@gitlab/ui'; import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; @@ -42,6 +42,7 @@ import { packageFiles, packageDestroyFilesMutation, packageDestroyFilesMutationError, + pagination, } from '../mock_data'; jest.mock('~/flash'); @@ -122,7 +123,9 @@ describe('PackagesApp', () => { const findDeleteFileModal = () => wrapper.findByTestId('delete-file-modal'); const findDeleteFilesModal = () => wrapper.findByTestId('delete-files-modal'); const findVersionsList = () => wrapper.findComponent(PackageVersionsList); - const findDependenciesCountBadge = () => wrapper.findComponent(GlBadge); + const findVersionsCountBadge = () => wrapper.findByTestId('other-versions-badge'); + const findNoVersionsMessage = () => wrapper.findByTestId('no-versions-message'); + const findDependenciesCountBadge = () => wrapper.findByTestId('dependencies-badge'); const findNoDependenciesMessage = () => wrapper.findByTestId('no-dependencies-message'); const findDependencyRows = () => wrapper.findAllComponents(DependencyRow); const findDeletePackage = () => wrapper.findComponent(DeletePackage); @@ -564,6 +567,30 @@ describe('PackagesApp', () => { await waitForPromises(); expect(findVersionsList()).toBeDefined(); + expect(findVersionsCountBadge().exists()).toBe(true); + expect(findVersionsCountBadge().text()).toBe(packageVersions().length.toString()); + }); + + it('displays tab with 0 count when package has no other versions', async () => { + createComponent({ + resolver: jest.fn().mockResolvedValue( + packageDetailsQuery({ + versions: { + count: 0, + nodes: [], + pageInfo: pagination({ hasNextPage: false, hasPreviousPage: false }), + }, + }), + ), + }); + + await waitForPromises(); + + expect(findVersionsCountBadge().exists()).toBe(true); + expect(findVersionsCountBadge().text()).toBe('0'); + expect(findNoVersionsMessage().text()).toMatchInterpolatedText( + 'There are no other versions of this package.', + ); }); it('binds the correct props', async () => { @@ -576,6 +603,7 @@ describe('PackagesApp', () => { }); }); }); + describe('dependency links', () => { it('does not show the dependency links for a non nuget package', async () => { createComponent(); diff --git a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js index 8e08864bdb8..cbb5aa52694 100644 --- a/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js +++ b/spec/frontend/packages_and_registries/settings/project/settings/components/container_expiration_policy_form_spec.js @@ -232,6 +232,7 @@ describe('Container Expiration Policy Settings Form', () => { describe('form', () => { describe('form submit event', () => { useMockLocationHelper(); + const originalHref = window.location.href; it('save has type submit', () => { mountComponent(); @@ -319,7 +320,7 @@ describe('Container Expiration Policy Settings Form', () => { await submitForm(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE); - expect(window.location.href).toBeUndefined(); + expect(window.location.href).toBe(originalHref); }); it('parses the error messages', async () => { diff --git a/spec/frontend/packages_and_registries/shared/utils_spec.js b/spec/frontend/packages_and_registries/shared/utils_spec.js index 962cb2257ce..d81cdbfd8bd 100644 --- a/spec/frontend/packages_and_registries/shared/utils_spec.js +++ b/spec/frontend/packages_and_registries/shared/utils_spec.js @@ -1,4 +1,3 @@ -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; import { getQueryParams, keyValueToFilterToken, @@ -7,6 +6,7 @@ import { beautifyPath, getCommitLink, } from '~/packages_and_registries/shared/utils'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import { packageList } from 'jest/packages_and_registries/infrastructure_registry/components/mock_data'; diff --git a/spec/frontend/pages/dashboard/todos/index/todos_spec.js b/spec/frontend/pages/dashboard/todos/index/todos_spec.js index 03aed7454e3..825aef27327 100644 --- a/spec/frontend/pages/dashboard/todos/index/todos_spec.js +++ b/spec/frontend/pages/dashboard/todos/index/todos_spec.js @@ -4,7 +4,6 @@ import waitForPromises from 'helpers/wait_for_promises'; import '~/lib/utils/common_utils'; import axios from '~/lib/utils/axios_utils'; import { addDelimiter } from '~/lib/utils/text_utility'; -import { visitUrl } from '~/lib/utils/url_utility'; import Todos from '~/pages/dashboard/todos/index/todos'; jest.mock('~/lib/utils/url_utility', () => ({ @@ -15,12 +14,10 @@ const TEST_COUNT_BIG = 2000; const TEST_DONE_COUNT_BIG = 7300; describe('Todos', () => { - let todoItem; let mock; beforeEach(() => { loadHTMLFixture('todos/todos.html'); - todoItem = document.querySelector('.todos-list .todo'); mock = new MockAdapter(axios); return new Todos(); @@ -34,95 +31,47 @@ describe('Todos', () => { mock.restore(); }); - describe('goToTodoUrl', () => { - it('opens the todo url', () => { - const todoLink = todoItem.dataset.url; + describe('on done todo click', () => { + let onToggleSpy; - let expectedUrl = null; - visitUrl.mockImplementation((url) => { - expectedUrl = url; - }); + beforeEach(() => { + const el = document.querySelector('.js-done-todo'); + const path = el.dataset.href; - todoItem.click(); + // Arrange + mock + .onDelete(path) + .replyOnce(200, { count: TEST_COUNT_BIG, done_count: TEST_DONE_COUNT_BIG }); + onToggleSpy = jest.fn(); + document.addEventListener('todo:toggle', onToggleSpy); - expect(expectedUrl).toEqual(todoLink); - }); - - describe('meta click', () => { - let windowOpenSpy; - let metakeyEvent; - - beforeEach(() => { - metakeyEvent = new MouseEvent('click', { ctrlKey: true }); - windowOpenSpy = jest.spyOn(window, 'open').mockImplementation(() => {}); - }); - - it('opens the todo url in another tab', () => { - const todoLink = todoItem.dataset.url; - - document.querySelectorAll('.todos-list .todo').forEach((el) => { - el.dispatchEvent(metakeyEvent); - }); - - expect(visitUrl).not.toHaveBeenCalled(); - expect(windowOpenSpy).toHaveBeenCalledWith(todoLink, '_blank'); - }); - - it('run native funcionality when avatar is clicked', () => { - document.querySelectorAll('.todos-list a').forEach((el) => { - el.addEventListener('click', (e) => e.preventDefault()); - }); - document.querySelectorAll('.todos-list img').forEach((el) => { - el.dispatchEvent(metakeyEvent); - }); + // Act + el.click(); - expect(visitUrl).not.toHaveBeenCalled(); - expect(windowOpenSpy).not.toHaveBeenCalled(); - }); + // Wait for axios and HTML to udpate + return waitForPromises(); }); - describe('on done todo click', () => { - let onToggleSpy; - - beforeEach(() => { - const el = document.querySelector('.js-done-todo'); - const path = el.dataset.href; - - // Arrange - mock - .onDelete(path) - .replyOnce(200, { count: TEST_COUNT_BIG, done_count: TEST_DONE_COUNT_BIG }); - onToggleSpy = jest.fn(); - document.addEventListener('todo:toggle', onToggleSpy); - - // Act - el.click(); - - // Wait for axios and HTML to udpate - return waitForPromises(); - }); - - it('dispatches todo:toggle', () => { - expect(onToggleSpy).toHaveBeenCalledWith( - expect.objectContaining({ - detail: { - count: TEST_COUNT_BIG, - }, - }), - ); - }); + it('dispatches todo:toggle', () => { + expect(onToggleSpy).toHaveBeenCalledWith( + expect.objectContaining({ + detail: { + count: TEST_COUNT_BIG, + }, + }), + ); + }); - it('updates pending text', () => { - expect(document.querySelector('.js-todos-pending .js-todos-badge').innerHTML).toEqual( - addDelimiter(TEST_COUNT_BIG), - ); - }); + it('updates pending text', () => { + expect(document.querySelector('.js-todos-pending .js-todos-badge').innerHTML).toEqual( + addDelimiter(TEST_COUNT_BIG), + ); + }); - it('updates done text', () => { - expect(document.querySelector('.js-todos-done .js-todos-badge').innerHTML).toEqual( - addDelimiter(TEST_DONE_COUNT_BIG), - ); - }); + it('updates done text', () => { + expect(document.querySelector('.js-todos-done .js-todos-badge').innerHTML).toEqual( + addDelimiter(TEST_DONE_COUNT_BIG), + ); }); }); }); diff --git a/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js index c1e1545944b..d60730e630b 100644 --- a/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js +++ b/spec/frontend/pages/import/fogbugz/new_user_map/components/user_select_spec.js @@ -1,6 +1,6 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import searchUsersQuery from '~/graphql_shared/queries/users_search_all.query.graphql'; @@ -59,7 +59,7 @@ describe('fogbugz user select component', () => { const id = 8; - wrapper.findComponent(GlListbox).vm.$emit('select', `gid://gitlab/User/${id}`); + wrapper.findComponent(GlCollapsibleListbox).vm.$emit('select', `gid://gitlab/User/${id}`); await nextTick(); expect(wrapper.get('input').attributes('value')).toBe(id.toString()); @@ -69,7 +69,7 @@ describe('fogbugz user select component', () => { createComponent(); jest.runOnlyPendingTimers(); - wrapper.findComponent(GlListbox).vm.$emit('search', 'test'); + wrapper.findComponent(GlCollapsibleListbox).vm.$emit('search', 'test'); await nextTick(); jest.runOnlyPendingTimers(); diff --git a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js index 727c5164cdc..9718d847ed5 100644 --- a/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js +++ b/spec/frontend/pages/projects/forks/new/components/fork_form_spec.js @@ -1,5 +1,5 @@ import { GlFormInputGroup, GlFormInput, GlForm, GlFormRadioGroup, GlFormRadio } from '@gitlab/ui'; -import { getByRole, getAllByRole } from '@testing-library/dom'; +import { getByRole } from '@testing-library/dom'; import { mount, shallowMount } from '@vue/test-utils'; import axios from 'axios'; import AxiosMockAdapter from 'axios-mock-adapter'; @@ -133,10 +133,15 @@ describe('ForkForm component', () => { expect(cancelButton.attributes('href')).toBe(projectFullPath); }); - const selectedMockNamespace = { name: 'two', full_name: 'two-group/two', id: 2 }; + const selectedMockNamespace = { + name: 'two', + full_name: 'two-group/two', + id: 2, + visibility: 'public', + }; - const fillForm = () => { - findForkUrlInput().vm.$emit('select', selectedMockNamespace); + const fillForm = (namespace = selectedMockNamespace) => { + findForkUrlInput().vm.$emit('select', namespace); }; it('has input with csrf token', () => { @@ -226,66 +231,139 @@ describe('ForkForm component', () => { }, ]; - it('resets the visibility to default "private"', async () => { + it('resets the visibility to max allowed below current level', async () => { + createFullComponent({ projectVisibility: 'public' }, { namespaces }); + + expect(wrapper.vm.form.fields.visibility.value).toBe('public'); + + fillForm({ + name: 'one', + id: 1, + visibility: 'internal', + }); + await nextTick(); + + expect(getByRole(wrapper.element, 'radio', { name: /internal/i }).checked).toBe(true); + }); + + it('does not reset the visibility when current level is allowed', async () => { + createFullComponent({ projectVisibility: 'public' }, { namespaces }); + + expect(wrapper.vm.form.fields.visibility.value).toBe('public'); + + fillForm({ + name: 'two', + id: 2, + visibility: 'public', + }); + await nextTick(); + + expect(getByRole(wrapper.element, 'radio', { name: /public/i }).checked).toBe(true); + }); + + it('does not reset the visibility when visibility cap is increased', async () => { createFullComponent({ projectVisibility: 'public' }, { namespaces }); expect(wrapper.vm.form.fields.visibility.value).toBe('public'); - fillForm(); + fillForm({ + name: 'three', + id: 3, + visibility: 'internal', + }); + await nextTick(); + + fillForm({ + name: 'four', + id: 4, + visibility: 'public', + }); + await nextTick(); + + expect(getByRole(wrapper.element, 'radio', { name: /internal/i }).checked).toBe(true); + }); + + it('sets the visibility to be next highest from current when restrictedVisibilityLevels is set', async () => { + createFullComponent( + { projectVisibility: 'public', restrictedVisibilityLevels: [10] }, + { namespaces }, + ); + + wrapper.vm.form.fields.visibility.value = 'internal'; + fillForm({ + name: 'five', + id: 5, + visibility: 'public', + }); await nextTick(); expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true); }); - it('sets the visibility to be null when restrictedVisibilityLevels is set', async () => { - createFullComponent({ restrictedVisibilityLevels: [10] }, { namespaces }); + it('sets the visibility to be next lowest from current when nothing lower is allowed', async () => { + createFullComponent( + { projectVisibility: 'public', restrictedVisibilityLevels: [0] }, + { namespaces }, + ); + + fillForm({ + name: 'six', + id: 6, + visibility: 'private', + }); + await nextTick(); + + expect(getByRole(wrapper.element, 'radio', { name: /private/i }).checked).toBe(true); - fillForm(); + fillForm({ + name: 'six', + id: 6, + visibility: 'public', + }); await nextTick(); - const container = getByRole(wrapper.element, 'radiogroup', { name: /visibility/i }); - const visibilityRadios = getAllByRole(container, 'radio'); - expect(visibilityRadios.filter((e) => e.checked)).toHaveLength(0); + expect(getByRole(wrapper.element, 'radio', { name: /internal/i }).checked).toBe(true); }); }); it.each` - project | restrictedVisibilityLevels - ${'private'} | ${[]} - ${'internal'} | ${[]} - ${'public'} | ${[]} - ${'private'} | ${[0]} - ${'private'} | ${[10]} - ${'private'} | ${[20]} - ${'private'} | ${[0, 10]} - ${'private'} | ${[0, 20]} - ${'private'} | ${[10, 20]} - ${'private'} | ${[0, 10, 20]} - ${'internal'} | ${[0]} - ${'internal'} | ${[10]} - ${'internal'} | ${[20]} - ${'internal'} | ${[0, 10]} - ${'internal'} | ${[0, 20]} - ${'internal'} | ${[10, 20]} - ${'internal'} | ${[0, 10, 20]} - ${'public'} | ${[0]} - ${'public'} | ${[10]} - ${'public'} | ${[0, 10]} - ${'public'} | ${[0, 20]} - ${'public'} | ${[10, 20]} - ${'public'} | ${[0, 10, 20]} - `('checks the correct radio button', ({ project, restrictedVisibilityLevels }) => { - createFullComponent({ - projectVisibility: project, - restrictedVisibilityLevels, - }); + project | restrictedVisibilityLevels | computedVisibilityLevel + ${'private'} | ${[]} | ${'private'} + ${'internal'} | ${[]} | ${'internal'} + ${'public'} | ${[]} | ${'public'} + ${'private'} | ${[0]} | ${'private'} + ${'private'} | ${[10]} | ${'private'} + ${'private'} | ${[20]} | ${'private'} + ${'private'} | ${[0, 10]} | ${'private'} + ${'private'} | ${[0, 20]} | ${'private'} + ${'private'} | ${[10, 20]} | ${'private'} + ${'private'} | ${[0, 10, 20]} | ${'private'} + ${'internal'} | ${[0]} | ${'internal'} + ${'internal'} | ${[10]} | ${'private'} + ${'internal'} | ${[20]} | ${'internal'} + ${'internal'} | ${[0, 10]} | ${'private'} + ${'internal'} | ${[0, 20]} | ${'internal'} + ${'internal'} | ${[10, 20]} | ${'private'} + ${'internal'} | ${[0, 10, 20]} | ${'private'} + ${'public'} | ${[0]} | ${'public'} + ${'public'} | ${[10]} | ${'public'} + ${'public'} | ${[0, 10]} | ${'public'} + ${'public'} | ${[0, 20]} | ${'internal'} + ${'public'} | ${[10, 20]} | ${'private'} + ${'public'} | ${[0, 10, 20]} | ${'private'} + `( + 'checks the correct radio button', + ({ project, restrictedVisibilityLevels, computedVisibilityLevel }) => { + createFullComponent({ + projectVisibility: project, + restrictedVisibilityLevels, + }); - if (restrictedVisibilityLevels.length === 0) { - expect(wrapper.find('[name="visibility"]:checked').attributes('value')).toBe(project); - } else { - expect(wrapper.find('[name="visibility"]:checked').exists()).toBe(false); - } - }); + expect(wrapper.find('[name="visibility"]:checked').attributes('value')).toBe( + computedVisibilityLevel, + ); + }, + ); it.each` project | namespace | privateIsDisabled | internalIsDisabled | publicIsDisabled | restrictedVisibilityLevels 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 21a38f066d9..e7c7ec0d336 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 @@ -36,62 +36,48 @@ exports[`Code Coverage when fetching data is successful matches the snapshot 1`] <!----> - <gl-dropdown-stub + <gl-base-dropdown-stub + ariahaspopup="listbox" category="primary" - clearalltext="Clear all" - clearalltextclass="gl-px-5" - headertext="" - hideheaderborder="true" - highlighteditemstitle="Selected" - highlighteditemstitleclass="gl-px-5" + icon="" size="medium" - text="rspec" + toggleid="dropdown-toggle-btn-6" + toggletext="rspec" variant="default" > - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischecked="true" - ischeckitem="true" - secondarytext="" - value="rspec" - > - - rspec - - </gl-dropdown-item-stub> - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckitem="true" - secondarytext="" - value="cypress" + <!----> + + <!----> + + <ul + aria-labelledby="dropdown-toggle-btn-6" + class="gl-dropdown-contents gl-list-style-none gl-pl-0 gl-mb-0" + id="listbox" + role="listbox" + tabindex="-1" > - - cypress - - </gl-dropdown-item-stub> - <gl-dropdown-item-stub - avatarurl="" - iconcolor="" - iconname="" - iconrightarialabel="" - iconrightname="" - ischeckitem="true" - secondarytext="" - value="karma" - > - - karma - - </gl-dropdown-item-stub> - </gl-dropdown-stub> + <gl-listbox-item-stub + isselected="true" + > + + rspec + + </gl-listbox-item-stub> + <gl-listbox-item-stub> + + cypress + + </gl-listbox-item-stub> + <gl-listbox-item-stub> + + karma + + </gl-listbox-item-stub> + </ul> + + <!----> + + </gl-base-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 2f2edd6b025..e99734963e3 100644 --- a/spec/frontend/pages/projects/graphs/code_coverage_spec.js +++ b/spec/frontend/pages/projects/graphs/code_coverage_spec.js @@ -1,4 +1,4 @@ -import { GlAlert, GlDropdown, GlDropdownItem } from '@gitlab/ui'; +import { GlAlert, GlListbox, GlListboxItem } from '@gitlab/ui'; import { GlAreaChart } from '@gitlab/ui/dist/charts'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; @@ -22,9 +22,10 @@ describe('Code Coverage', () => { const findAlert = () => wrapper.findComponent(GlAlert); const findAreaChart = () => wrapper.findComponent(GlAreaChart); - const findAllDropdownItems = () => wrapper.findAllComponents(GlDropdownItem); - const findFirstDropdownItem = () => findAllDropdownItems().at(0); - const findSecondDropdownItem = () => findAllDropdownItems().at(1); + const findListBox = () => wrapper.findComponent(GlListbox); + const findListBoxItems = () => wrapper.findAllComponents(GlListboxItem); + const findFirstListBoxItem = () => findListBoxItems().at(0); + const findSecondListBoxItem = () => findListBoxItems().at(1); const findDownloadButton = () => wrapper.find('[data-testid="download-button"]'); const createComponent = () => { @@ -36,6 +37,7 @@ describe('Code Coverage', () => { graphRef, graphCsvPath, }, + stubs: { GlListbox }, }); }; @@ -142,9 +144,9 @@ describe('Code Coverage', () => { }); it('renders the dropdown with all custom names as options', () => { - expect(wrapper.findComponent(GlDropdown).exists()).toBeDefined(); - expect(findAllDropdownItems()).toHaveLength(codeCoverageMockData.length); - expect(findFirstDropdownItem().text()).toBe(codeCoverageMockData[0].group_name); + expect(findListBox().exists()).toBe(true); + expect(findListBoxItems()).toHaveLength(codeCoverageMockData.length); + expect(findFirstListBoxItem().text()).toBe(codeCoverageMockData[0].group_name); }); }); @@ -159,19 +161,19 @@ describe('Code Coverage', () => { }); it('updates the selected dropdown option with an icon', async () => { - findSecondDropdownItem().vm.$emit('click'); + findListBox().vm.$emit('select', '1'); await nextTick(); - expect(findFirstDropdownItem().attributes('ischecked')).toBe(undefined); - expect(findSecondDropdownItem().attributes('ischecked')).toBe('true'); + expect(findFirstListBoxItem().attributes('isselected')).toBeUndefined(); + expect(findSecondListBoxItem().attributes('isselected')).toBe('true'); }); it('updates the graph data when selecting a different option in dropdown', async () => { const originalSelectedData = wrapper.vm.selectedDailyCoverage; const expectedData = codeCoverageMockData[1]; - findSecondDropdownItem().vm.$emit('click'); + findListBox().vm.$emit('select', '1'); await nextTick(); 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 deleted file mode 100644 index 4cac642bb50..00000000000 --- a/spec/frontend/pages/projects/pipeline_schedules/shared/components/timezone_dropdown_spec.js +++ /dev/null @@ -1,116 +0,0 @@ -import { formatUtcOffset, formatTimezone } from '~/lib/utils/datetime_utility'; -import { findTimezoneByIdentifier } from '~/pages/projects/pipeline_schedules/shared/components/timezone_dropdown'; - -describe('Timezone Dropdown', () => { - describe('formatUtcOffset', () => { - it('will convert negative utc offsets in seconds to hours and minutes', () => { - expect(formatUtcOffset(-21600)).toEqual('- 6'); - }); - - it('will convert positive utc offsets in seconds to hours and minutes', () => { - expect(formatUtcOffset(25200)).toEqual('+ 7'); - expect(formatUtcOffset(49500)).toEqual('+ 13.75'); - }); - - it('will return 0 when given a string', () => { - expect(formatUtcOffset('BLAH')).toEqual('0'); - expect(formatUtcOffset('$%$%')).toEqual('0'); - }); - - it('will return 0 when given an array', () => { - expect(formatUtcOffset(['an', 'array'])).toEqual('0'); - }); - - it('will return 0 when given an object', () => { - expect(formatUtcOffset({ some: '', object: '' })).toEqual('0'); - }); - - it('will return 0 when given null', () => { - expect(formatUtcOffset(null)).toEqual('0'); - }); - - it('will return 0 when given undefined', () => { - expect(formatUtcOffset(undefined)).toEqual('0'); - }); - - it('will return 0 when given empty input', () => { - expect(formatUtcOffset('')).toEqual('0'); - }); - }); - - describe('formatTimezone', () => { - it('given name: "Chatham Is.", offset: "49500", will format for display as "[UTC + 13.75] Chatham Is."', () => { - expect( - formatTimezone({ - name: 'Chatham Is.', - offset: 49500, - identifier: 'Pacific/Chatham', - }), - ).toEqual('[UTC + 13.75] Chatham Is.'); - }); - - it('given name: "Saskatchewan", offset: "-21600", will format for display as "[UTC - 6] Saskatchewan"', () => { - expect( - formatTimezone({ - name: 'Saskatchewan', - offset: -21600, - identifier: 'America/Regina', - }), - ).toEqual('[UTC - 6] Saskatchewan'); - }); - - it('given name: "Accra", offset: "0", will format for display as "[UTC 0] Accra"', () => { - expect( - formatTimezone({ - name: 'Accra', - offset: 0, - identifier: 'Africa/Accra', - }), - ).toEqual('[UTC 0] Accra'); - }); - }); - - describe('findTimezoneByIdentifier', () => { - const tzList = [ - { - identifier: 'Asia/Tokyo', - name: 'Sapporo', - offset: 32400, - }, - { - identifier: 'Asia/Hong_Kong', - name: 'Hong Kong', - offset: 28800, - }, - { - identifier: 'Asia/Dhaka', - name: 'Dhaka', - offset: 21600, - }, - ]; - - const identifier = 'Asia/Dhaka'; - it('returns the correct object if the identifier exists', () => { - const res = findTimezoneByIdentifier(tzList, identifier); - - expect(res).toBe(tzList[2]); - }); - - it('returns null if it doesnt find the identifier', () => { - const res = findTimezoneByIdentifier(tzList, 'Australia/Melbourne'); - - expect(res).toBeNull(); - }); - - it('returns null if there is no identifier given', () => { - expect(findTimezoneByIdentifier(tzList)).toBeNull(); - expect(findTimezoneByIdentifier(tzList, '')).toBeNull(); - }); - - it('returns null if there is an empty or invalid array given', () => { - expect(findTimezoneByIdentifier([], identifier)).toBeNull(); - expect(findTimezoneByIdentifier(null, identifier)).toBeNull(); - expect(findTimezoneByIdentifier(undefined, identifier)).toBeNull(); - }); - }); -}); 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 b202a148306..38f7a2e919d 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 @@ -24,7 +24,6 @@ const defaultProps = { buildsAccessLevel: 20, wikiAccessLevel: 20, snippetsAccessLevel: 20, - operationsAccessLevel: 20, metricsDashboardAccessLevel: 20, pagesAccessLevel: 10, analyticsAccessLevel: 20, @@ -114,9 +113,14 @@ describe('Settings Panel', () => { const findPackageSettings = () => wrapper.findComponent({ ref: 'package-settings' }); const findPackageAccessLevel = () => wrapper.find('[data-testid="package-registry-access-level"]'); - const findPackageAccessLevels = () => - wrapper.find('[name="project[project_feature_attributes][package_registry_access_level]"]'); const findPackagesEnabledInput = () => wrapper.find('[name="project[packages_enabled]"]'); + const findPackageRegistryEnabledInput = () => wrapper.find('[name="package_registry_enabled"]'); + const findPackageRegistryAccessLevelHiddenInput = () => + wrapper.find( + 'input[name="project[project_feature_attributes][package_registry_access_level]"]', + ); + const findPackageRegistryApiForEveryoneEnabledInput = () => + wrapper.find('[name="package_registry_api_for_everyone_enabled"]'); const findPagesSettings = () => wrapper.findComponent({ ref: 'pages-settings' }); const findPagesAccessLevels = () => wrapper.find('[name="project[project_feature_attributes][pages_access_level]"]'); @@ -131,9 +135,6 @@ describe('Settings Panel', () => { wrapper.findComponent({ ref: 'metrics-visibility-settings' }); const findMetricsVisibilityInput = () => findMetricsVisibilitySettings().findComponent(ProjectFeatureSetting); - const findOperationsSettings = () => wrapper.findComponent({ ref: 'operations-settings' }); - const findOperationsVisibilityInput = () => - findOperationsSettings().findComponent(ProjectFeatureSetting); const findConfirmDangerButton = () => wrapper.findComponent(ConfirmDanger); const findEnvironmentsSettings = () => wrapper.findComponent({ ref: 'environments-settings' }); const findFeatureFlagsSettings = () => wrapper.findComponent({ ref: 'feature-flags-settings' }); @@ -141,6 +142,8 @@ describe('Settings Panel', () => { wrapper.findComponent({ ref: 'infrastructure-settings' }); const findReleasesSettings = () => wrapper.findComponent({ ref: 'environments-settings' }); const findMonitorSettings = () => wrapper.findComponent({ ref: 'monitor-settings' }); + const findMonitorVisibilityInput = () => + findMonitorSettings().findComponent(ProjectFeatureSetting); afterEach(() => { wrapper.destroy(); @@ -283,7 +286,7 @@ describe('Settings Panel', () => { }); expect(findRepositoryFeatureProjectRow().props('helpText')).toBe( - 'View and edit files in this project. Non-project members have only read access.', + 'View and edit files in this project. When set to **Everyone With Access** non-project members have only read access.', ); }); }); @@ -587,28 +590,63 @@ describe('Settings Panel', () => { expect(findPackageAccessLevel().exists()).toBe(true); }); + it('has hidden input field for package registry access level', () => { + wrapper = mountComponent({ + glFeatures: { packageRegistryAccessLevel: true }, + packagesAvailable: true, + }); + + expect(findPackageRegistryAccessLevelHiddenInput().exists()).toBe(true); + }); + it.each` - visibilityLevel | output - ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${[[featureAccessLevel.PROJECT_MEMBERS, 'Only Project Members'], [30, 'Everyone']]} - ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${[[featureAccessLevel.EVERYONE, 'Everyone With Access'], [30, 'Everyone']]} - ${VISIBILITY_LEVEL_PUBLIC_INTEGER} | ${[[30, 'Everyone']]} + projectVisibilityLevel | packageRegistryEnabled | packageRegistryApiForEveryoneEnabled | expectedAccessLevel + ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${false} | ${'disabled'} | ${featureAccessLevel.NOT_ENABLED} + ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${true} | ${false} | ${featureAccessLevel.PROJECT_MEMBERS} + ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${true} | ${true} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${false} | ${'disabled'} | ${featureAccessLevel.NOT_ENABLED} + ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${true} | ${false} | ${featureAccessLevel.EVERYONE} + ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${true} | ${true} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} + ${VISIBILITY_LEVEL_PUBLIC_INTEGER} | ${false} | ${'hidden'} | ${featureAccessLevel.NOT_ENABLED} + ${VISIBILITY_LEVEL_PUBLIC_INTEGER} | ${true} | ${'hidden'} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} `( - 'renders correct options when visibilityLevel is $visibilityLevel', - async ({ visibilityLevel, output }) => { + 'sets correct access level', + async ({ + projectVisibilityLevel, + packageRegistryEnabled, + packageRegistryApiForEveryoneEnabled, + expectedAccessLevel, + }) => { wrapper = mountComponent({ glFeatures: { packageRegistryAccessLevel: true }, packagesAvailable: true, currentSettings: { - visibilityLevel, + visibilityLevel: projectVisibilityLevel, }, }); - expect(findPackageAccessLevels().props('options')).toStrictEqual(output); + await findPackageRegistryEnabledInput().vm.$emit('change', packageRegistryEnabled); + + const packageRegistryApiForEveryoneEnabledInput = findPackageRegistryApiForEveryoneEnabledInput(); + + if (packageRegistryApiForEveryoneEnabled === 'hidden') { + expect(packageRegistryApiForEveryoneEnabledInput.exists()).toBe(false); + } else if (packageRegistryApiForEveryoneEnabled === 'disabled') { + expect(packageRegistryApiForEveryoneEnabledInput.props('disabled')).toBe(true); + } else { + expect(packageRegistryApiForEveryoneEnabledInput.props('disabled')).toBe(false); + await packageRegistryApiForEveryoneEnabledInput.vm.$emit( + 'change', + packageRegistryApiForEveryoneEnabled, + ); + } + + expect(wrapper.vm.packageRegistryAccessLevel).toBe(expectedAccessLevel); }, ); it.each` - initialProjectVisibilityLevel | newProjectVisibilityLevel | initialPackageRegistryOption | expectedPackageRegistryOption + initialProjectVisibilityLevel | newProjectVisibilityLevel | initialAccessLevel | expectedAccessLevel ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.EVERYONE} ${VISIBILITY_LEVEL_PRIVATE_INTEGER} | ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} @@ -626,27 +664,25 @@ describe('Settings Panel', () => { ${VISIBILITY_LEVEL_PUBLIC_INTEGER} | ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${featureAccessLevel.NOT_ENABLED} | ${featureAccessLevel.NOT_ENABLED} ${VISIBILITY_LEVEL_PUBLIC_INTEGER} | ${VISIBILITY_LEVEL_INTERNAL_INTEGER} | ${FEATURE_ACCESS_LEVEL_ANONYMOUS} | ${featureAccessLevel.EVERYONE} `( - 'changes option from $initialPackageRegistryOption to $expectedPackageRegistryOption when visibilityLevel changed from $initialProjectVisibilityLevel to $newProjectVisibilityLevel', + 'changes access level when project visibility level changed', async ({ initialProjectVisibilityLevel, newProjectVisibilityLevel, - initialPackageRegistryOption, - expectedPackageRegistryOption, + initialAccessLevel, + expectedAccessLevel, }) => { wrapper = mountComponent({ glFeatures: { packageRegistryAccessLevel: true }, packagesAvailable: true, currentSettings: { visibilityLevel: initialProjectVisibilityLevel, - packageRegistryAccessLevel: initialPackageRegistryOption, + packageRegistryAccessLevel: initialAccessLevel, }, }); await findProjectVisibilityLevelInput().setValue(newProjectVisibilityLevel); - expect(findPackageAccessLevels().props('value')).toStrictEqual( - expectedPackageRegistryOption, - ); + expect(wrapper.vm.packageRegistryAccessLevel).toBe(expectedAccessLevel); }, ); }); @@ -751,27 +787,27 @@ describe('Settings Panel', () => { ${featureAccessLevel.EVERYONE} | ${featureAccessLevel.NOT_ENABLED} ${featureAccessLevel.PROJECT_MEMBERS} | ${featureAccessLevel.NOT_ENABLED} `( - 'when updating Operations Settings access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well', + 'when updating Monitor access level from `$before` to `$after`, Metric Dashboard access is updated to `$after` as well', async ({ before, after }) => { wrapper = mountComponent({ - currentSettings: { operationsAccessLevel: before, metricsDashboardAccessLevel: before }, + currentSettings: { monitorAccessLevel: before, metricsDashboardAccessLevel: before }, }); - await findOperationsVisibilityInput().vm.$emit('change', after); + await findMonitorVisibilityInput().vm.$emit('change', after); expect(findMetricsVisibilityInput().props('value')).toBe(after); }, ); - it('when updating Operations Settings access level from `10` to `20`, Metric Dashboard access is not increased', async () => { + it('when updating Monitor access level from `10` to `20`, Metric Dashboard access is not increased', async () => { wrapper = mountComponent({ currentSettings: { - operationsAccessLevel: featureAccessLevel.PROJECT_MEMBERS, + monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS, metricsDashboardAccessLevel: featureAccessLevel.PROJECT_MEMBERS, }, }); - await findOperationsVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE); + await findMonitorVisibilityInput().vm.$emit('change', featureAccessLevel.EVERYONE); expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.PROJECT_MEMBERS); }); @@ -780,7 +816,7 @@ describe('Settings Panel', () => { wrapper = mountComponent({ currentSettings: { visibilityLevel: VISIBILITY_LEVEL_PUBLIC_INTEGER, - operationsAccessLevel: featureAccessLevel.EVERYONE, + monitorAccessLevel: featureAccessLevel.EVERYONE, metricsDashboardAccessLevel: featureAccessLevel.EVERYONE, }, }); @@ -799,84 +835,32 @@ describe('Settings Panel', () => { }); }); - describe('Operations', () => { - it('should show the operations toggle', () => { - wrapper = mountComponent(); - - expect(findOperationsSettings().exists()).toBe(true); - }); - }); - describe('Environments', () => { - describe('with feature flag', () => { - it('should show the environments toggle', () => { - wrapper = mountComponent({ - glFeatures: { splitOperationsVisibilityPermissions: true }, - }); + it('should show the environments toggle', () => { + wrapper = mountComponent({}); - expect(findEnvironmentsSettings().exists()).toBe(true); - }); - }); - describe('without feature flag', () => { - it('should not show the environments toggle', () => { - wrapper = mountComponent({}); - - expect(findEnvironmentsSettings().exists()).toBe(false); - }); + expect(findEnvironmentsSettings().exists()).toBe(true); }); }); describe('Feature Flags', () => { - describe('with feature flag', () => { - it('should show the feature flags toggle', () => { - wrapper = mountComponent({ - glFeatures: { splitOperationsVisibilityPermissions: true }, - }); - - expect(findFeatureFlagsSettings().exists()).toBe(true); - }); - }); - describe('without feature flag', () => { - it('should not show the feature flags toggle', () => { - wrapper = mountComponent({}); + it('should show the feature flags toggle', () => { + wrapper = mountComponent({}); - expect(findFeatureFlagsSettings().exists()).toBe(false); - }); + expect(findFeatureFlagsSettings().exists()).toBe(true); }); }); describe('Infrastructure', () => { - describe('with feature flag', () => { - it('should show the infrastructure toggle', () => { - wrapper = mountComponent({ - glFeatures: { splitOperationsVisibilityPermissions: true }, - }); + it('should show the infrastructure toggle', () => { + wrapper = mountComponent({}); - expect(findInfrastructureSettings().exists()).toBe(true); - }); - }); - describe('without feature flag', () => { - it('should not show the infrastructure toggle', () => { - wrapper = mountComponent({}); - - expect(findInfrastructureSettings().exists()).toBe(false); - }); + expect(findInfrastructureSettings().exists()).toBe(true); }); }); describe('Releases', () => { - describe('with feature flag', () => { - it('should show the releases toggle', () => { - wrapper = mountComponent({ - glFeatures: { splitOperationsVisibilityPermissions: true }, - }); + it('should show the releases toggle', () => { + wrapper = mountComponent({}); - expect(findReleasesSettings().exists()).toBe(true); - }); - }); - describe('without feature flag', () => { - it('should not show the releases toggle', () => { - wrapper = mountComponent({}); - - expect(findReleasesSettings().exists()).toBe(false); - }); + expect(findReleasesSettings().exists()).toBe(true); }); }); describe('Monitor', () => { @@ -884,37 +868,20 @@ describe('Settings Panel', () => { [10, 'Only Project Members'], [20, 'Everyone With Access'], ]; - describe('with feature flag', () => { - it('shows Monitor toggle instead of Operations toggle', () => { - wrapper = mountComponent({ - glFeatures: { splitOperationsVisibilityPermissions: true }, - }); - - expect(findMonitorSettings().exists()).toBe(true); - expect(findOperationsSettings().exists()).toBe(false); - expect(findMonitorSettings().findComponent(ProjectFeatureSetting).props('options')).toEqual( - expectedAccessLevel, - ); - }); - it('when monitorAccessLevel is for project members, it is also for everyone', () => { - wrapper = mountComponent({ - glFeatures: { splitOperationsVisibilityPermissions: true }, - currentSettings: { monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS }, - }); + it('shows Monitor toggle instead of Operations toggle', () => { + wrapper = mountComponent({}); - expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.EVERYONE); - }); + expect(findMonitorSettings().exists()).toBe(true); + expect(findMonitorSettings().findComponent(ProjectFeatureSetting).props('options')).toEqual( + expectedAccessLevel, + ); }); - describe('without feature flag', () => { - it('shows Operations toggle instead of Monitor toggle', () => { - wrapper = mountComponent({}); - - expect(findMonitorSettings().exists()).toBe(false); - expect(findOperationsSettings().exists()).toBe(true); - expect( - findOperationsSettings().findComponent(ProjectFeatureSetting).props('options'), - ).toEqual(expectedAccessLevel); + it('when monitorAccessLevel is for project members, it is also for everyone', () => { + wrapper = mountComponent({ + currentSettings: { monitorAccessLevel: featureAccessLevel.PROJECT_MEMBERS }, }); + + expect(findMetricsVisibilityInput().props('value')).toBe(featureAccessLevel.EVERYONE); }); }); }); diff --git a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js index 982c81b9272..7c9aae13d25 100644 --- a/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js +++ b/spec/frontend/pages/shared/wikis/components/wiki_content_spec.js @@ -3,13 +3,13 @@ import { nextTick } from 'vue'; import { shallowMount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import WikiContent from '~/pages/shared/wikis/components/wiki_content.vue'; -import { renderGFM } from '~/pages/shared/wikis/render_gfm_facade'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import axios from '~/lib/utils/axios_utils'; import httpStatus from '~/lib/utils/http_status'; import waitForPromises from 'helpers/wait_for_promises'; import { handleLocationHash } from '~/lib/utils/common_utils'; -jest.mock('~/pages/shared/wikis/render_gfm_facade'); +jest.mock('~/behaviors/markdown/render_gfm'); jest.mock('~/lib/utils/common_utils'); describe('pages/shared/wikis/components/wiki_content', () => { diff --git a/spec/frontend/performance_bar/components/detailed_metric_spec.js b/spec/frontend/performance_bar/components/detailed_metric_spec.js index 437d51e02ba..5ab2c9abe5d 100644 --- a/spec/frontend/performance_bar/components/detailed_metric_spec.js +++ b/spec/frontend/performance_bar/components/detailed_metric_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlDropdownItem } from '@gitlab/ui'; import { nextTick } from 'vue'; import { trimText } from 'helpers/text_helper'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; @@ -31,12 +30,8 @@ describe('detailedMetric', () => { const findExpandedBacktraceBtnAtIndex = (index) => findExpandBacktraceBtns().at(index); const findDetailsLabel = () => wrapper.findByTestId('performance-bar-details-label'); const findSortOrderDropdown = () => wrapper.findByTestId('performance-bar-sort-order'); - const clickSortOrderDropdownItem = (sortOrder) => - findSortOrderDropdown() - .findAllComponents(GlDropdownItem) - .filter((item) => item.text() === sortOrderOptions[sortOrder]) - .at(0) - .vm.$emit('click'); + const selectSortOrder = (sortOrder) => + findSortOrderDropdown().vm.$emit('select', sortOrderOptions[sortOrder].value); const findEmptyDetailNotice = () => wrapper.findByTestId('performance-bar-empty-detail-notice'); const findAllDetailDurations = () => wrapper.findAllByTestId('performance-item-duration').wrappers.map((w) => w.text()); @@ -334,11 +329,11 @@ describe('detailedMetric', () => { }); it('changes sortOrder on select', async () => { - clickSortOrderDropdownItem(sortOrders.CHRONOLOGICAL); + selectSortOrder(sortOrders.CHRONOLOGICAL); await nextTick(); expect(findAllDetailDurations()).toEqual(['23ms', '100ms', '75ms']); - clickSortOrderDropdownItem(sortOrders.DURATION); + selectSortOrder(sortOrders.DURATION); await nextTick(); expect(findAllDetailDurations()).toEqual(['100ms', '75ms', '23ms']); }); diff --git a/spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js deleted file mode 100644 index 512b152f106..00000000000 --- a/spec/frontend/pipeline_new/components/legacy_pipeline_new_form_spec.js +++ /dev/null @@ -1,456 +0,0 @@ -import { GlForm, GlSprintf, GlLoadingIcon } from '@gitlab/ui'; -import { mount, shallowMount } from '@vue/test-utils'; -import MockAdapter from 'axios-mock-adapter'; -import { nextTick } from 'vue'; -import CreditCardValidationRequiredAlert from 'ee_component/billings/components/cc_validation_required_alert.vue'; -import { TEST_HOST } from 'helpers/test_constants'; -import waitForPromises from 'helpers/wait_for_promises'; -import axios from '~/lib/utils/axios_utils'; -import httpStatusCodes from '~/lib/utils/http_status'; -import { redirectTo } from '~/lib/utils/url_utility'; -import LegacyPipelineNewForm from '~/pipeline_new/components/legacy_pipeline_new_form.vue'; -import RefsDropdown from '~/pipeline_new/components/refs_dropdown.vue'; -import { - mockQueryParams, - mockPostParams, - mockProjectId, - mockError, - mockRefs, - mockCreditCardValidationRequiredError, -} from '../mock_data'; - -jest.mock('~/lib/utils/url_utility', () => ({ - redirectTo: jest.fn(), -})); - -const projectRefsEndpoint = '/root/project/refs'; -const pipelinesPath = '/root/project/-/pipelines'; -const configVariablesPath = '/root/project/-/pipelines/config_variables'; -const newPipelinePostResponse = { id: 1 }; -const defaultBranch = 'main'; - -describe('Pipeline New Form', () => { - let wrapper; - let mock; - let dummySubmitEvent; - - const findForm = () => wrapper.findComponent(GlForm); - const findRefsDropdown = () => wrapper.findComponent(RefsDropdown); - const findSubmitButton = () => wrapper.find('[data-testid="run_pipeline_button"]'); - const findVariableRows = () => wrapper.findAll('[data-testid="ci-variable-row"]'); - const findRemoveIcons = () => wrapper.findAll('[data-testid="remove-ci-variable-row"]'); - const findDropdowns = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-type"]'); - const findKeyInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-key"]'); - const findValueInputs = () => wrapper.findAll('[data-testid="pipeline-form-ci-variable-value"]'); - const findErrorAlert = () => wrapper.find('[data-testid="run-pipeline-error-alert"]'); - const findWarningAlert = () => wrapper.find('[data-testid="run-pipeline-warning-alert"]'); - const findWarningAlertSummary = () => findWarningAlert().findComponent(GlSprintf); - const findWarnings = () => wrapper.findAll('[data-testid="run-pipeline-warning"]'); - const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findCCAlert = () => wrapper.findComponent(CreditCardValidationRequiredAlert); - const getFormPostParams = () => JSON.parse(mock.history.post[0].data); - - const selectBranch = (branch) => { - // Select a branch in the dropdown - findRefsDropdown().vm.$emit('input', { - shortName: branch, - fullName: `refs/heads/${branch}`, - }); - }; - - const createComponent = (props = {}, method = shallowMount) => { - wrapper = method(LegacyPipelineNewForm, { - provide: { - projectRefsEndpoint, - }, - propsData: { - projectId: mockProjectId, - pipelinesPath, - configVariablesPath, - defaultBranch, - refParam: defaultBranch, - settingsLink: '', - maxWarnings: 25, - ...props, - }, - }); - }; - - beforeEach(() => { - mock = new MockAdapter(axios); - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, {}); - mock.onGet(projectRefsEndpoint).reply(httpStatusCodes.OK, mockRefs); - - dummySubmitEvent = { - preventDefault: jest.fn(), - }; - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - - mock.restore(); - }); - - describe('Form', () => { - beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - - await waitForPromises(); - }); - - it('displays the correct values for the provided query params', async () => { - expect(findDropdowns().at(0).props('text')).toBe('Variable'); - expect(findDropdowns().at(1).props('text')).toBe('File'); - expect(findRefsDropdown().props('value')).toEqual({ shortName: 'tag-1' }); - expect(findVariableRows()).toHaveLength(3); - }); - - it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(0).element.value).toBe('test_var'); - expect(findValueInputs().at(0).element.value).toBe('test_var_val'); - }); - - it('displays an empty variable for the user to fill out', async () => { - expect(findKeyInputs().at(2).element.value).toBe(''); - expect(findValueInputs().at(2).element.value).toBe(''); - expect(findDropdowns().at(2).props('text')).toBe('Variable'); - }); - - it('does not display remove icon for last row', () => { - expect(findRemoveIcons()).toHaveLength(2); - }); - - it('removes ci variable row on remove icon button click', async () => { - findRemoveIcons().at(1).trigger('click'); - - await nextTick(); - - expect(findVariableRows()).toHaveLength(2); - }); - - it('creates blank variable on input change event', async () => { - const input = findKeyInputs().at(2); - input.element.value = 'test_var_2'; - input.trigger('change'); - - await nextTick(); - - expect(findVariableRows()).toHaveLength(4); - expect(findKeyInputs().at(3).element.value).toBe(''); - expect(findValueInputs().at(3).element.value).toBe(''); - }); - }); - - describe('Pipeline creation', () => { - beforeEach(async () => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.OK, newPipelinePostResponse); - - await waitForPromises(); - }); - - it('does not submit the native HTML form', async () => { - createComponent(); - - findForm().vm.$emit('submit', dummySubmitEvent); - - expect(dummySubmitEvent.preventDefault).toHaveBeenCalled(); - }); - - it('disables the submit button immediately after submitting', async () => { - createComponent(); - - expect(findSubmitButton().props('disabled')).toBe(false); - - findForm().vm.$emit('submit', dummySubmitEvent); - await waitForPromises(); - - expect(findSubmitButton().props('disabled')).toBe(true); - }); - - it('creates pipeline with full ref and variables', async () => { - createComponent(); - - findForm().vm.$emit('submit', dummySubmitEvent); - await waitForPromises(); - - expect(getFormPostParams().ref).toEqual(`refs/heads/${defaultBranch}`); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); - }); - - it('creates a pipeline with short ref and variables from the query params', async () => { - createComponent(mockQueryParams); - - await waitForPromises(); - - findForm().vm.$emit('submit', dummySubmitEvent); - - await waitForPromises(); - - expect(getFormPostParams()).toEqual(mockPostParams); - expect(redirectTo).toHaveBeenCalledWith(`${pipelinesPath}/${newPipelinePostResponse.id}`); - }); - }); - - describe('When the ref has been changed', () => { - beforeEach(async () => { - createComponent({}, mount); - - await waitForPromises(); - }); - it('variables persist between ref changes', async () => { - selectBranch('main'); - - await waitForPromises(); - - const mainInput = findKeyInputs().at(0); - mainInput.element.value = 'build_var'; - mainInput.trigger('change'); - - await nextTick(); - - selectBranch('branch-1'); - - await waitForPromises(); - - const branchOneInput = findKeyInputs().at(0); - branchOneInput.element.value = 'deploy_var'; - branchOneInput.trigger('change'); - - await nextTick(); - - selectBranch('main'); - - await waitForPromises(); - - expect(findKeyInputs().at(0).element.value).toBe('build_var'); - expect(findVariableRows().length).toBe(2); - - selectBranch('branch-1'); - - await waitForPromises(); - - expect(findKeyInputs().at(0).element.value).toBe('deploy_var'); - expect(findVariableRows().length).toBe(2); - }); - }); - - describe('when yml defines a variable', () => { - const mockYmlKey = 'yml_var'; - const mockYmlValue = 'yml_var_val'; - const mockYmlMultiLineValue = `A value - with multiple - lines`; - const mockYmlDesc = 'A var from yml.'; - - it('loading icon is shown when content is requested and hidden when received', async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); - - expect(findLoadingIcon().exists()).toBe(true); - - await waitForPromises(); - - expect(findLoadingIcon().exists()).toBe(false); - }); - - it('multi-line strings are added to the value field without removing line breaks', async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlMultiLineValue, - description: mockYmlDesc, - }, - }); - - await waitForPromises(); - - expect(findValueInputs().at(0).element.value).toBe(mockYmlMultiLineValue); - }); - - describe('with description', () => { - beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: mockYmlDesc, - }, - }); - - await waitForPromises(); - }); - - it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(4); - }); - - it('displays a variable from yml', () => { - expect(findKeyInputs().at(0).element.value).toBe(mockYmlKey); - expect(findValueInputs().at(0).element.value).toBe(mockYmlValue); - }); - - it('displays a variable from provided query params', () => { - expect(findKeyInputs().at(1).element.value).toBe('test_var'); - expect(findValueInputs().at(1).element.value).toBe('test_var_val'); - }); - - it('adds a description to the first variable from yml', () => { - expect(findVariableRows().at(0).text()).toContain(mockYmlDesc); - }); - - it('removes the description when a variable key changes', async () => { - findKeyInputs().at(0).element.value = 'yml_var_modified'; - findKeyInputs().at(0).trigger('change'); - - await nextTick(); - - expect(findVariableRows().at(0).text()).not.toContain(mockYmlDesc); - }); - }); - - describe('without description', () => { - beforeEach(async () => { - createComponent(mockQueryParams, mount); - - mock.onGet(configVariablesPath).reply(httpStatusCodes.OK, { - [mockYmlKey]: { - value: mockYmlValue, - description: null, - }, - yml_var2: { - value: 'yml_var2_val', - }, - yml_var3: { - description: '', - }, - }); - - await waitForPromises(); - }); - - it('displays all the variables', async () => { - expect(findVariableRows()).toHaveLength(3); - }); - }); - }); - - describe('Form errors and warnings', () => { - beforeEach(() => { - createComponent(); - }); - - describe('when the refs cannot be loaded', () => { - beforeEach(() => { - mock - .onGet(projectRefsEndpoint, { params: { search: '' } }) - .reply(httpStatusCodes.INTERNAL_SERVER_ERROR); - - findRefsDropdown().vm.$emit('loadingError'); - }); - - it('shows both an error alert', () => { - expect(findErrorAlert().exists()).toBe(true); - expect(findWarningAlert().exists()).toBe(false); - }); - }); - - describe('when the error response can be handled', () => { - beforeEach(async () => { - mock.onPost(pipelinesPath).reply(httpStatusCodes.BAD_REQUEST, mockError); - - findForm().vm.$emit('submit', dummySubmitEvent); - - await waitForPromises(); - }); - - it('shows both error and warning', () => { - expect(findErrorAlert().exists()).toBe(true); - expect(findWarningAlert().exists()).toBe(true); - }); - - it('shows the correct error', () => { - expect(findErrorAlert().text()).toBe(mockError.errors[0]); - }); - - it('shows the correct warning title', () => { - const { length } = mockError.warnings; - - expect(findWarningAlertSummary().attributes('message')).toBe(`${length} warnings found:`); - }); - - it('shows the correct amount of warnings', () => { - expect(findWarnings()).toHaveLength(mockError.warnings.length); - }); - - it('re-enables the submit button', () => { - expect(findSubmitButton().props('disabled')).toBe(false); - }); - - it('does not show the credit card validation required alert', () => { - expect(findCCAlert().exists()).toBe(false); - }); - - describe('when the error response is credit card validation required', () => { - beforeEach(async () => { - mock - .onPost(pipelinesPath) - .reply(httpStatusCodes.BAD_REQUEST, mockCreditCardValidationRequiredError); - - window.gon = { - subscriptions_url: TEST_HOST, - payment_form_url: TEST_HOST, - }; - - findForm().vm.$emit('submit', dummySubmitEvent); - - await waitForPromises(); - }); - - it('shows credit card validation required alert', () => { - expect(findErrorAlert().exists()).toBe(false); - expect(findCCAlert().exists()).toBe(true); - }); - - it('clears error and hides the alert on dismiss', async () => { - expect(findCCAlert().exists()).toBe(true); - expect(wrapper.vm.$data.error).toBe(mockCreditCardValidationRequiredError.errors[0]); - - findCCAlert().vm.$emit('dismiss'); - - await nextTick(); - - expect(findCCAlert().exists()).toBe(false); - expect(wrapper.vm.$data.error).toBe(null); - }); - }); - }); - - describe('when the error response cannot be handled', () => { - beforeEach(async () => { - mock - .onPost(pipelinesPath) - .reply(httpStatusCodes.INTERNAL_SERVER_ERROR, 'something went wrong'); - - findForm().vm.$emit('submit', dummySubmitEvent); - - await waitForPromises(); - }); - - it('re-enables the submit button', () => { - expect(findSubmitButton().props('disabled')).toBe(false); - }); - }); - }); -}); diff --git a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js index 3e699b93fd3..2360dd7d103 100644 --- a/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js +++ b/spec/frontend/pipeline_new/components/pipeline_new_form_spec.js @@ -295,11 +295,11 @@ describe('Pipeline New Form', () => { expect(dropdownItems.at(2).text()).toBe(valueOptions[2]); }); - it('variables with multiple predefined values sets the first option as the default', () => { + it('variable with multiple predefined values sets value as the default', () => { const dropdown = findValueDropdowns().at(0); const { valueOptions } = mockYamlVariables[2]; - expect(dropdown.props('text')).toBe(valueOptions[0]); + expect(dropdown.props('text')).toBe(valueOptions[1]); }); }); diff --git a/spec/frontend/pipeline_new/mock_data.js b/spec/frontend/pipeline_new/mock_data.js index e95a65171fc..2af0ef4d7c4 100644 --- a/spec/frontend/pipeline_new/mock_data.js +++ b/spec/frontend/pipeline_new/mock_data.js @@ -83,7 +83,7 @@ export const mockYamlVariables = [ { description: 'This is a variable with predefined values.', key: 'VAR_WITH_OPTIONS', - value: 'development', + value: 'staging', valueOptions: ['development', 'staging', 'production'], }, ]; @@ -105,7 +105,7 @@ export const mockYamlVariablesWithoutDesc = [ { description: null, key: 'VAR_WITH_OPTIONS', - value: 'development', + value: 'staging', valueOptions: ['development', 'staging', 'production'], }, ]; diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js index 7fa8a18ea1f..036b82530d5 100644 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_mini_graph_spec.js @@ -48,7 +48,6 @@ describe('Pipeline Mini Graph', () => { isMergeTrain: false, pipelinePath: '', stages: expect.any(Array), - stagesClass: '', updateDropdown: false, upstreamPipeline: undefined, }); @@ -63,15 +62,6 @@ describe('Pipeline Mini Graph', () => { expect(findUpstreamArrowIcon().exists()).toBe(false); expect(findDownstreamArrowIcon().exists()).toBe(false); }); - - it('triggers events in "action request complete"', () => { - createComponent(); - - findPipelineMiniGraph(0).vm.$emit('pipelineActionRequestComplete'); - findPipelineMiniGraph(1).vm.$emit('pipelineActionRequestComplete'); - - expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(2); - }); }); describe('rendered state with upstream pipeline', () => { @@ -92,7 +82,6 @@ describe('Pipeline Mini Graph', () => { isMergeTrain: false, pipelinePath: '', stages: expect.any(Array), - stagesClass: '', updateDropdown: false, upstreamPipeline: expect.any(Object), }); @@ -124,7 +113,6 @@ describe('Pipeline Mini Graph', () => { isMergeTrain: false, pipelinePath: 'my/pipeline/path', stages: expect.any(Array), - stagesClass: '', updateDropdown: false, upstreamPipeline: undefined, }); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js index 52b440f18bb..b7a9297d856 100644 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stage_spec.js @@ -186,7 +186,7 @@ describe('Pipelines stage component', () => { }); }); - describe('pipelineActionRequestComplete', () => { + describe('job update in dropdown', () => { beforeEach(async () => { mock.onGet(dropdownPath).reply(200, stageReply); mock.onPost(`${stageReply.latest_statuses[0].status.action.path}.json`).reply(200); @@ -204,24 +204,11 @@ describe('Pipelines stage component', () => { await findCiActionBtn().trigger('click'); }; - it('closes dropdown when job item action is clicked', async () => { - const hidden = jest.fn(); - - wrapper.vm.$root.$on('bv::dropdown::hide', hidden); - - expect(hidden).toHaveBeenCalledTimes(0); - - await clickCiAction(); - await waitForPromises(); - - expect(hidden).toHaveBeenCalledTimes(1); - }); - - it('emits `pipelineActionRequestComplete` when job item action is clicked', async () => { + it('keeps dropdown open when job item action is clicked', async () => { await clickCiAction(); await waitForPromises(); - expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(1); + expect(findDropdown().classes('show')).toBe(true); }); }); diff --git a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js index bfb780d5d39..c123f53886e 100644 --- a/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js +++ b/spec/frontend/pipelines/components/pipeline_mini_graph/pipeline_stages_spec.js @@ -26,12 +26,6 @@ describe('Pipeline Stages', () => { expect(findPipelineStages()).toHaveLength(mockStages.length); }); - it('renders stages with a custom class', () => { - createComponent({ stagesClass: 'my-class' }); - - expect(wrapper.findAll('.my-class')).toHaveLength(mockStages.length); - }); - it('does not fail when stages are empty', () => { createComponent({ stages: [] }); @@ -39,15 +33,6 @@ describe('Pipeline Stages', () => { expect(findPipelineStages()).toHaveLength(0); }); - it('triggers events in "action request complete" in stages', () => { - createComponent(); - - findPipelineStagesAt(0).vm.$emit('pipelineActionRequestComplete'); - findPipelineStagesAt(1).vm.$emit('pipelineActionRequestComplete'); - - expect(wrapper.emitted('pipelineActionRequestComplete')).toHaveLength(2); - }); - it('update dropdown is false by default', () => { createComponent(); diff --git a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js index ee3eaaf5ef3..ba7262353f0 100644 --- a/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js +++ b/spec/frontend/pipelines/components/pipelines_filtered_search_spec.js @@ -6,7 +6,10 @@ import { mockTracking, unmockTracking } from 'helpers/tracking_helper'; import Api from '~/api'; import axios from '~/lib/utils/axios_utils'; import PipelinesFilteredSearch from '~/pipelines/components/pipelines_list/pipelines_filtered_search.vue'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, +} from '~/vue_shared/components/filtered_search_bar/constants'; import { TRACKING_CATEGORIES } from '~/pipelines/constants'; import { users, mockSearch, branches, tags } from '../mock_data'; @@ -63,7 +66,7 @@ describe('Pipelines filtered search', () => { title: 'Trigger author', unique: true, projectId: '21', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }); expect(findBranchToken()).toMatchObject({ @@ -73,7 +76,7 @@ describe('Pipelines filtered search', () => { unique: true, projectId: '21', defaultBranchName: 'main', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }); expect(findSourceToken()).toMatchObject({ @@ -81,7 +84,7 @@ describe('Pipelines filtered search', () => { icon: 'trigger-source', title: 'Source', unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }); expect(findStatusToken()).toMatchObject({ @@ -89,7 +92,7 @@ describe('Pipelines filtered search', () => { icon: 'status', title: 'Status', unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }); expect(findTagToken()).toMatchObject({ @@ -97,7 +100,7 @@ describe('Pipelines filtered search', () => { icon: 'tag', title: 'Tag name', unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, }); }); @@ -111,7 +114,7 @@ describe('Pipelines filtered search', () => { it('disables tag name token when branch name token is active', async () => { findFilteredSearch().vm.$emit('input', [ { type: 'ref', value: { data: 'branch-1', operator: '=' } }, - { type: 'filtered-search-term', value: { data: '' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, ]); await nextTick(); @@ -122,7 +125,7 @@ describe('Pipelines filtered search', () => { it('disables branch name token when tag name token is active', async () => { findFilteredSearch().vm.$emit('input', [ { type: 'tag', value: { data: 'tag-1', operator: '=' } }, - { type: 'filtered-search-term', value: { data: '' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, ]); await nextTick(); @@ -139,7 +142,7 @@ describe('Pipelines filtered search', () => { }); it('resets tokens disabled state when clearing tokens by backspace', async () => { - findFilteredSearch().vm.$emit('input', [{ type: 'filtered-search-term', value: { data: '' } }]); + findFilteredSearch().vm.$emit('input', [{ type: FILTERED_SEARCH_TERM, value: { data: '' } }]); await nextTick(); expect(findBranchToken().disabled).toBe(false); @@ -172,7 +175,7 @@ describe('Pipelines filtered search', () => { operator: '=', }, }, - { type: 'filtered-search-term', value: { data: '' } }, + { type: FILTERED_SEARCH_TERM, value: { data: '' } }, ]; expect(findFilteredSearch().props('value')).toMatchObject(expectedValueProp); diff --git a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js index b537c81da3f..f255e0d857f 100644 --- a/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js +++ b/spec/frontend/pipelines/components/pipelines_list/empty_state/pipelines_ci_templates_spec.js @@ -13,7 +13,7 @@ import { RUNNERS_DOCUMENTATION_LINK_CLICKED_EVENT, RUNNERS_SETTINGS_BUTTON_CLICKED_EVENT, I18N, -} from '~/pipeline_editor/constants'; +} from '~/ci/pipeline_editor/constants'; const pipelineEditorPath = '/-/ci/editor'; const ciRunnerSettingsPath = '/-/settings/ci_cd'; diff --git a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js index d9199f3b0f7..df10742fd93 100644 --- a/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js +++ b/spec/frontend/pipelines/pipeline_graph/pipeline_graph_spec.js @@ -1,7 +1,7 @@ import { GlAlert } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { setHTMLFixture } from 'helpers/fixtures'; -import { CI_CONFIG_STATUS_VALID } from '~/pipeline_editor/constants'; +import { CI_CONFIG_STATUS_VALID } from '~/ci/pipeline_editor/constants'; import LinksInner from '~/pipelines/components/graph_shared/links_inner.vue'; import LinksLayer from '~/pipelines/components/graph_shared/links_layer.vue'; import JobPill from '~/pipelines/components/pipeline_graph/job_pill.vue'; diff --git a/spec/frontend/pipelines/pipelines_table_spec.js b/spec/frontend/pipelines/pipelines_table_spec.js index 044683ce533..740037a5ac8 100644 --- a/spec/frontend/pipelines/pipelines_table_spec.js +++ b/spec/frontend/pipelines/pipelines_table_spec.js @@ -17,7 +17,6 @@ import { TRACKING_CATEGORIES, } from '~/pipelines/constants'; -import eventHub from '~/pipelines/event_hub'; import CiBadge from '~/vue_shared/components/ci_badge_link.vue'; jest.mock('~/pipelines/event_hub'); @@ -134,12 +133,6 @@ describe('Pipelines Table', () => { expect(findPipelineMiniGraph().props('stages')).toHaveLength(0); }); }); - - it('when action request is complete, should refresh table', () => { - findPipelineMiniGraph().vm.$emit('pipelineActionRequestComplete'); - - expect(eventHub.$emit).toHaveBeenCalledWith('refreshPipelinesTable'); - }); }); describe('duration cell', () => { diff --git a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js index 94f9a37f707..c090fd353f7 100644 --- a/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js +++ b/spec/frontend/pipelines/tokens/pipeline_status_token_spec.js @@ -2,6 +2,10 @@ import { GlFilteredSearchToken, GlFilteredSearchSuggestion, GlIcon } from '@gitl import { shallowMount } from '@vue/test-utils'; import { stubComponent } from 'helpers/stub_component'; import PipelineStatusToken from '~/pipelines/components/pipelines_list/tokens/pipeline_status_token.vue'; +import { + TOKEN_TITLE_STATUS, + TOKEN_TYPE_STATUS, +} from '~/vue_shared/components/filtered_search_bar/constants'; describe('Pipeline Status Token', () => { let wrapper; @@ -13,9 +17,9 @@ describe('Pipeline Status Token', () => { const defaultProps = { config: { - type: 'status', + type: TOKEN_TYPE_STATUS, icon: 'status', - title: 'Status', + title: TOKEN_TITLE_STATUS, unique: true, }, value: { diff --git a/spec/frontend/popovers/components/popovers_spec.js b/spec/frontend/popovers/components/popovers_spec.js index eba6b95214d..1299e7277d1 100644 --- a/spec/frontend/popovers/components/popovers_spec.js +++ b/spec/frontend/popovers/components/popovers_spec.js @@ -57,12 +57,13 @@ describe('popovers/components/popovers.vue', () => { describe('supports HTML content', () => { const svgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>'; + const escapedSvgIcon = '<svg><use xlink:href="icons.svg#test"></use></svg>'; it.each` description | content | render ${'renders html content correctly'} | ${'<b>HTML</b>'} | ${'<b>HTML</b>'} ${'removes any unsafe content'} | ${'<script>alert(XSS)</script>'} | ${''} - ${'renders svg icons correctly'} | ${svgIcon} | ${svgIcon} + ${'renders svg icons correctly'} | ${svgIcon} | ${escapedSvgIcon} `('$description', async ({ content, render }) => { await buildWrapper(createPopoverTarget({ content, html: true })); diff --git a/spec/frontend/projects/commit/components/branches_dropdown_spec.js b/spec/frontend/projects/commit/components/branches_dropdown_spec.js index e2848e615c3..a84dd246f5d 100644 --- a/spec/frontend/projects/commit/components/branches_dropdown_spec.js +++ b/spec/frontend/projects/commit/components/branches_dropdown_spec.js @@ -13,7 +13,7 @@ describe('BranchesDropdown', () => { let store; const spyFetchBranches = jest.fn(); - const createComponent = (term, state = { isFetching: false }) => { + const createComponent = (props, state = { isFetching: false }) => { store = new Vuex.Store({ getters: { joinedBranches: () => ['_main_', '_branch_1_', '_branch_2_'], @@ -28,7 +28,8 @@ describe('BranchesDropdown', () => { shallowMount(BranchesDropdown, { store, propsData: { - value: term, + value: props.value, + blanked: props.blanked || false, }, }), ); @@ -48,23 +49,40 @@ describe('BranchesDropdown', () => { describe('On mount', () => { beforeEach(() => { - createComponent(''); + createComponent({ value: '' }); }); it('invokes fetchBranches', () => { expect(spyFetchBranches).toHaveBeenCalled(); }); + + describe('with a value but visually blanked', () => { + beforeEach(() => { + createComponent({ value: '_main_', blanked: true }, { branch: '_main_' }); + }); + + it('renders all branches', () => { + expect(findAllDropdownItems()).toHaveLength(3); + expect(findDropdownItemByIndex(0).text()).toBe('_main_'); + expect(findDropdownItemByIndex(1).text()).toBe('_branch_1_'); + expect(findDropdownItemByIndex(2).text()).toBe('_branch_2_'); + }); + + it('selects the active branch', () => { + expect(wrapper.vm.isSelected('_main_')).toBe(true); + }); + }); }); describe('Loading states', () => { it('shows loading icon while fetching', () => { - createComponent('', { isFetching: true }); + createComponent({ value: '' }, { isFetching: true }); expect(findLoading().isVisible()).toBe(true); }); it('does not show loading icon', () => { - createComponent(''); + createComponent({ value: '' }); expect(findLoading().isVisible()).toBe(false); }); @@ -72,7 +90,7 @@ describe('BranchesDropdown', () => { describe('No branches found', () => { beforeEach(() => { - createComponent('_non_existent_branch_'); + createComponent({ value: '_non_existent_branch_' }); }); it('renders empty results message', () => { @@ -90,7 +108,7 @@ describe('BranchesDropdown', () => { describe('Search term is empty', () => { beforeEach(() => { - createComponent(''); + createComponent({ value: '' }); }); it('renders all branches when search term is empty', () => { @@ -107,7 +125,7 @@ describe('BranchesDropdown', () => { describe('When searching', () => { beforeEach(() => { - createComponent(''); + createComponent({ value: '' }); }); it('invokes fetchBranches', async () => { @@ -124,7 +142,7 @@ describe('BranchesDropdown', () => { describe('Branches found', () => { beforeEach(() => { - createComponent('_branch_1_', { branch: '_branch_1_' }); + createComponent({ value: '_branch_1_' }, { branch: '_branch_1_' }); }); it('renders only the branch searched for', () => { @@ -156,7 +174,7 @@ describe('BranchesDropdown', () => { describe('Case insensitive for search term', () => { beforeEach(() => { - createComponent('_BrAnCh_1_'); + createComponent({ value: '_BrAnCh_1_' }); }); it('renders only the branch searched for', () => { diff --git a/spec/frontend/projects/new/components/new_project_url_select_spec.js b/spec/frontend/projects/new/components/new_project_url_select_spec.js index b6d4ee32cf5..67532cea61e 100644 --- a/spec/frontend/projects/new/components/new_project_url_select_spec.js +++ b/spec/frontend/projects/new/components/new_project_url_select_spec.js @@ -63,6 +63,8 @@ describe('NewProjectUrlSelect component', () => { rootUrl: 'https://gitlab.com/', trackLabel: 'blank_project', userNamespaceId: '1', + inputId: 'input_id', + inputName: 'input_name', }; let mockQueryResponse; @@ -92,7 +94,7 @@ describe('NewProjectUrlSelect component', () => { const findDropdown = () => wrapper.findComponent(GlDropdown); const findSelectedPath = () => wrapper.findComponent(GlTruncate); const findInput = () => wrapper.findComponent(GlSearchBoxByType); - const findHiddenNamespaceInput = () => wrapper.find('[name="project[namespace_id]"]'); + const findHiddenNamespaceInput = () => wrapper.find(`[name="${defaultProvide.inputName}`); const findHiddenSelectedNamespaceInput = () => wrapper.find('[name="project[selected_namespace_id]"]'); @@ -165,6 +167,8 @@ describe('NewProjectUrlSelect component', () => { it("renders a hidden input with the user's namespace id", () => { expect(findHiddenNamespaceInput().attributes('value')).toBe(defaultProvide.userNamespaceId); + expect(findHiddenNamespaceInput().attributes('name')).toBe(defaultProvide.inputName); + expect(findHiddenNamespaceInput().attributes('id')).toBe(defaultProvide.inputId); }); it('renders a hidden input with the selected namespace id', () => { @@ -198,6 +202,18 @@ describe('NewProjectUrlSelect component', () => { expect(listItems.at(5).text()).toBe(data.currentUser.namespace.fullPath); }); + it('does not render users section when user namespace id is not provided', async () => { + wrapper = mountComponent({ + mountFn: mount, + provide: { ...defaultProvide, userNamespaceId: null }, + }); + + await showDropdown(); + + expect(wrapper.findAllComponents(GlDropdownSectionHeader)).toHaveLength(1); + expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(0).text()).toBe('Groups'); + }); + describe('query fetching', () => { describe('on component mount', () => { it('does not fetch query', () => { @@ -297,7 +313,7 @@ describe('NewProjectUrlSelect component', () => { ); }); - it('tracks clicking on the dropdown', () => { + it('tracks clicking on the dropdown when trackLabel is provided', () => { wrapper = mountComponent(); const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); @@ -311,4 +327,16 @@ describe('NewProjectUrlSelect component', () => { unmockTracking(); }); + + it('does not track clicking on the dropdown when trackLabel is not provided', () => { + wrapper = mountComponent({ provide: { ...defaultProvide, trackLabel: null } }); + + const trackingSpy = mockTracking(undefined, wrapper.element, jest.spyOn); + + findDropdown().vm.$emit('show'); + + expect(trackingSpy).not.toHaveBeenCalled(); + + unmockTracking(); + }); }); diff --git a/spec/frontend/projects/project_new_spec.js b/spec/frontend/projects/project_new_spec.js index 4fcecc3a307..d69bfc4ec92 100644 --- a/spec/frontend/projects/project_new_spec.js +++ b/spec/frontend/projects/project_new_spec.js @@ -1,12 +1,14 @@ import { setHTMLFixture, resetHTMLFixture } from 'helpers/fixtures'; import { TEST_HOST } from 'helpers/test_constants'; import projectNew from '~/projects/project_new'; +import { checkRules } from '~/projects/project_name_rules'; import { mockTracking, triggerEvent, unmockTracking } from 'helpers/tracking_helper'; describe('New Project', () => { let $projectImportUrl; let $projectPath; let $projectName; + let $projectNameError; const mockKeyup = (el) => el.dispatchEvent(new KeyboardEvent('keyup')); const mockChange = (el) => el.dispatchEvent(new Event('change')); @@ -29,6 +31,7 @@ describe('New Project', () => { </div> </div> <input id="project_name" /> + <div class="gl-field-error hidden" id="project_name_error" /> <input id="project_path" /> </div> <div class="js-user-readme-repo"></div> @@ -41,6 +44,7 @@ describe('New Project', () => { $projectImportUrl = document.querySelector('#project_import_url'); $projectPath = document.querySelector('#project_path'); $projectName = document.querySelector('#project_name'); + $projectNameError = document.querySelector('#project_name_error'); }); afterEach(() => { @@ -84,6 +88,57 @@ describe('New Project', () => { }); }); + describe('tracks manual name input', () => { + beforeEach(() => { + projectNew.bindEvents(); + }); + + afterEach(() => { + unmockTracking(); + }); + + it('no error message by default', () => { + expect($projectNameError.classList.contains('hidden')).toBe(true); + }); + + it('show error message if name is validate', () => { + $projectName.value = '.validate!Name'; + triggerEvent($projectName, 'change'); + + expect($projectNameError.innerText).toBe( + "Name must start with a letter, digit, emoji, or '_'", + ); + expect($projectNameError.classList.contains('hidden')).toBe(false); + }); + }); + + describe('project name rule', () => { + describe("Name must start with a letter, digit, emoji, or '_'", () => { + const errormsg = "Name must start with a letter, digit, emoji, or '_'"; + it("'.foo' should error", () => { + const text = '.foo'; + expect(checkRules(text)).toBe(errormsg); + }); + it('_foo should passed', () => { + const text = '_foo'; + expect(checkRules(text)).toBe(''); + }); + }); + + describe("Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces", () => { + const errormsg = + "Name can contain only letters, digits, emojis, '_', '.', '+', dashes, or spaces"; + it("'foo(#^.^#)foo' should error", () => { + const text = 'foo(#^.^#)foo'; + expect(checkRules(text)).toBe(errormsg); + }); + it("'foo123😊_.+- ' should passed", () => { + const text = 'foo123😊_.+- '; + expect(checkRules(text)).toBe(''); + }); + }); + }); + describe('deriveProjectPathFromUrl', () => { const dummyImportUrl = `${TEST_HOST}/dummy/import/url.git`; diff --git a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js index 27065a704e2..bc373d9deb7 100644 --- a/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js +++ b/spec/frontend/projects/settings/branch_rules/components/view/index_spec.js @@ -16,10 +16,12 @@ import { branchProtectionsMockResponse, approvalRulesMock, statusChecksRulesMock, + matchingBranchesCount, } from './mock_data'; jest.mock('~/lib/utils/url_utility', () => ({ getParameterByName: jest.fn().mockReturnValue('main'), + mergeUrlParams: jest.fn().mockReturnValue('/branches?state=all&search=main'), joinPaths: jest.fn(), })); @@ -65,6 +67,13 @@ describe('View branch rules', () => { const findForcePushTitle = () => wrapper.findByText(I18N.allowForcePushDescription); const findApprovalsTitle = () => wrapper.findByText(I18N.approvalsTitle); const findStatusChecksTitle = () => wrapper.findByText(I18N.statusChecksTitle); + const findMatchingBranchesLink = () => + wrapper.findByText( + sprintf(I18N.matchingBranchesLinkTitle, { + total: matchingBranchesCount, + subject: 'branches', + }), + ); it('gets the branch param from url and renders it in the view', () => { expect(util.getParameterByName).toHaveBeenCalledWith('branch'); @@ -85,6 +94,12 @@ describe('View branch rules', () => { expect(findBranchTitle().exists()).toBe(true); }); + it('renders matching branches link', () => { + const matchingBranchesLink = findMatchingBranchesLink(); + expect(matchingBranchesLink.exists()).toBe(true); + expect(matchingBranchesLink.attributes().href).toBe('/branches?state=all&search=main'); + }); + it('renders a branch protection title', () => { expect(findBranchProtectionTitle().exists()).toBe(true); }); diff --git a/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js b/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js index c07d4673344..821dba75b62 100644 --- a/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js +++ b/spec/frontend/projects/settings/branch_rules/components/view/mock_data.js @@ -109,6 +109,8 @@ export const accessLevelsMockResponse = [ }, ]; +export const matchingBranchesCount = 3; + export const branchProtectionsMockResponse = { data: { project: { @@ -141,6 +143,7 @@ export const branchProtectionsMockResponse = { __typename: 'ExternalStatusCheckConnection', nodes: statusChecksRulesMock, }, + matchingBranchesCount, }, { __typename: 'BranchRule', @@ -166,6 +169,7 @@ export const branchProtectionsMockResponse = { __typename: 'ExternalStatusCheckConnection', nodes: [], }, + matchingBranchesCount, }, ], }, diff --git a/spec/frontend/projects/settings/mock_data.js b/spec/frontend/projects/settings/mock_data.js new file mode 100644 index 00000000000..0262c0e3e43 --- /dev/null +++ b/spec/frontend/projects/settings/mock_data.js @@ -0,0 +1,57 @@ +const accessLevelsMockResponse = [ + { + __typename: 'PushAccessLevelEdge', + node: { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Jona Langworth', + group: null, + user: { + __typename: 'UserCore', + id: '123', + webUrl: 'test.com', + name: 'peter', + avatarUrl: 'test.com/user.png', + }, + }, + }, + { + __typename: 'PushAccessLevelEdge', + node: { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Maintainers', + group: null, + user: null, + }, + }, +]; + +export const pushAccessLevelsMockResponse = { + __typename: 'PushAccessLevelConnection', + edges: accessLevelsMockResponse, +}; + +export const pushAccessLevelsMockResult = { + total: 2, + users: [ + { + src: 'test.com/user.png', + __typename: 'UserCore', + id: '123', + webUrl: 'test.com', + name: 'peter', + avatarUrl: 'test.com/user.png', + }, + ], + groups: [], + roles: [ + { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Maintainers', + group: null, + user: null, + }, + ], +}; diff --git a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js index 6369f04781f..447d7e86ceb 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/app_spec.js +++ b/spec/frontend/projects/settings/repository/branch_rules/app_spec.js @@ -5,9 +5,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import BranchRules, { i18n } from '~/projects/settings/repository/branch_rules/app.vue'; import BranchRule from '~/projects/settings/repository/branch_rules/components/branch_rule.vue'; -import branchRulesQuery from '~/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; +import branchRulesQuery from 'ee_else_ce/projects/settings/repository/branch_rules/graphql/queries/branch_rules.query.graphql'; import { createAlert } from '~/flash'; -import { branchRulesMockResponse, appProvideMock } from './mock_data'; +import { + branchRulesMockResponse, + appProvideMock, +} from 'ee_else_ce_jest/projects/settings/repository/branch_rules/mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js index 2aa93fd0e28..49c45c080b4 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js +++ b/spec/frontend/projects/settings/repository/branch_rules/components/branch_rule_spec.js @@ -50,17 +50,15 @@ describe('Branch rule', () => { it('renders the protection details list items', () => { expect(findProtectionDetailsListItems()).toHaveLength(wrapper.vm.approvalDetails.length); expect(findProtectionDetailsListItems().at(0).text()).toBe(i18n.allowForcePush); - expect(findProtectionDetailsListItems().at(1).text()).toBe(i18n.codeOwnerApprovalRequired); - expect(findProtectionDetailsListItems().at(2).text()).toMatchInterpolatedText( - sprintf(i18n.statusChecks, { - total: branchRulePropsMock.statusChecksTotal, - subject: n__('check', 'checks', branchRulePropsMock.statusChecksTotal), - }), - ); - expect(findProtectionDetailsListItems().at(3).text()).toMatchInterpolatedText( - sprintf(i18n.approvalRules, { - total: branchRulePropsMock.approvalRulesTotal, - subject: n__('rule', 'rules', branchRulePropsMock.approvalRulesTotal), + expect(findProtectionDetailsListItems().at(1).text()).toBe(wrapper.vm.pushAccessLevelsText); + }); + + it('renders branches count for wildcards', () => { + createComponent({ name: 'test-*' }); + expect(findProtectionDetailsListItems().at(0).text()).toMatchInterpolatedText( + sprintf(i18n.matchingBranches, { + total: branchRulePropsMock.matchingBranchesCount, + subject: n__('branch', 'branches', branchRulePropsMock.matchingBranchesCount), }), ); }); diff --git a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js index 8aa03a12996..6f506882c36 100644 --- a/spec/frontend/projects/settings/repository/branch_rules/mock_data.js +++ b/spec/frontend/projects/settings/repository/branch_rules/mock_data.js @@ -1,3 +1,22 @@ +export const accessLevelsMockResponse = [ + { + __typename: 'PushAccessLevelEdge', + node: { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Developers', + }, + }, + { + __typename: 'PushAccessLevelEdge', + node: { + __typename: 'PushAccessLevel', + accessLevel: 40, + accessLevelDescription: 'Maintainers', + }, + }, +]; + export const branchRulesMockResponse = { data: { project: { @@ -9,34 +28,34 @@ export const branchRulesMockResponse = { { name: 'main', isDefault: true, + matchingBranchesCount: 1, branchProtection: { allowForcePush: true, - codeOwnerApprovalRequired: true, - }, - approvalRules: { - nodes: [{ id: 1 }], - __typename: 'ApprovalProjectRuleConnection', - }, - externalStatusChecks: { - nodes: [{ id: 1 }, { id: 2 }], - __typename: 'BranchRule', + mergeAccessLevels: { + edges: [], + __typename: 'MergeAccessLevelConnection', + }, + pushAccessLevels: { + edges: accessLevelsMockResponse, + __typename: 'PushAccessLevelConnection', + }, }, __typename: 'BranchRule', }, { name: 'test-*', isDefault: false, + matchingBranchesCount: 2, branchProtection: { allowForcePush: false, - codeOwnerApprovalRequired: false, - }, - approvalRules: { - nodes: [], - __typename: 'ApprovalProjectRuleConnection', - }, - externalStatusChecks: { - nodes: [], - __typename: 'BranchRule', + mergeAccessLevels: { + edges: [], + __typename: 'MergeAccessLevelConnection', + }, + pushAccessLevels: { + edges: [], + __typename: 'PushAccessLevelConnection', + }, }, __typename: 'BranchRule', }, @@ -57,17 +76,22 @@ export const branchRuleProvideMock = { export const branchRulePropsMock = { name: 'main', isDefault: true, + matchingBranchesCount: 1, branchProtection: { allowForcePush: true, - codeOwnerApprovalRequired: true, + codeOwnerApprovalRequired: false, + pushAccessLevels: { + edges: accessLevelsMockResponse, + }, }, - approvalRulesTotal: 1, - statusChecksTotal: 2, + approvalRulesTotal: 0, + statusChecksTotal: 0, }; export const branchRuleWithoutDetailsPropsMock = { - name: 'main', + name: 'branch-1', isDefault: false, + matchingBranchesCount: 1, branchProtection: { allowForcePush: false, codeOwnerApprovalRequired: false, diff --git a/spec/frontend/projects/settings/utils_spec.js b/spec/frontend/projects/settings/utils_spec.js new file mode 100644 index 00000000000..319aa4000b5 --- /dev/null +++ b/spec/frontend/projects/settings/utils_spec.js @@ -0,0 +1,11 @@ +import { getAccessLevels } from '~/projects/settings/utils'; +import { pushAccessLevelsMockResponse, pushAccessLevelsMockResult } from './mock_data'; + +describe('Utils', () => { + describe('getAccessLevels', () => { + it('takes accessLevels response data and returns acecssLevels object', () => { + const pushAccessLevels = getAccessLevels(pushAccessLevelsMockResponse); + expect(pushAccessLevels).toEqual(pushAccessLevelsMockResult); + }); + }); +}); diff --git a/spec/frontend/releases/__snapshots__/util_spec.js.snap b/spec/frontend/releases/__snapshots__/util_spec.js.snap index d88d79d2cde..00fc521b716 100644 --- a/spec/frontend/releases/__snapshots__/util_spec.js.snap +++ b/spec/frontend/releases/__snapshots__/util_spec.js.snap @@ -43,16 +43,17 @@ Object { }, "author": Object { "__typename": "UserCore", - "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "avatarUrl": "https://www.gravatar.com/avatar/eb329fbfeccd9e6d45ff159da8736876?s=80&d=identicon", "id": Any<String>, - "username": "administrator", - "webUrl": "http://localhost/administrator", + "username": "user1", + "webUrl": "http://localhost/user1", }, "commit": Object { "shortId": "b83d6e39", "title": "Merge branch 'branch-merged' into 'master'", }, "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", + "createdAt": 2019-01-03T00:00:00.000Z, "descriptionHtml": "<p data-sourcepos=\\"1:1-1:23\\" dir=\\"auto\\">An okay release <gl-emoji title=\\"shrug\\" data-name=\\"shrug\\" data-unicode-version=\\"9.0\\">🤷</gl-emoji></p>", "evidences": Array [], "historicalRelease": false, @@ -140,16 +141,17 @@ Object { }, "author": Object { "__typename": "UserCore", - "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "avatarUrl": "https://www.gravatar.com/avatar/eb329fbfeccd9e6d45ff159da8736876?s=80&d=identicon", "id": Any<String>, - "username": "administrator", - "webUrl": "http://localhost/administrator", + "username": "user1", + "webUrl": "http://localhost/user1", }, "commit": Object { "shortId": "b83d6e39", "title": "Merge branch 'branch-merged' into 'master'", }, "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", + "createdAt": 2018-12-03T00:00:00.000Z, "descriptionHtml": "<p data-sourcepos=\\"1:1-1:33\\" dir=\\"auto\\">Best. Release. <strong>Ever.</strong> <gl-emoji title=\\"rocket\\" data-name=\\"rocket\\" data-unicode-version=\\"6.0\\">🚀</gl-emoji></p>", "evidences": Array [ Object { @@ -253,6 +255,7 @@ Object { "sources": Array [], }, "author": undefined, + "createdAt": 2018-12-03T00:00:00.000Z, "description": "Best. Release. **Ever.** :rocket:", "evidences": Array [], "milestones": Array [ @@ -362,16 +365,17 @@ Object { }, "author": Object { "__typename": "UserCore", - "avatarUrl": "https://www.gravatar.com/avatar/16f8e2050ce10180ca571c2eb19cfce2?s=80&d=identicon", + "avatarUrl": "https://www.gravatar.com/avatar/eb329fbfeccd9e6d45ff159da8736876?s=80&d=identicon", "id": Any<String>, - "username": "administrator", - "webUrl": "http://localhost/administrator", + "username": "user1", + "webUrl": "http://localhost/user1", }, "commit": Object { "shortId": "b83d6e39", "title": "Merge branch 'branch-merged' into 'master'", }, "commitPath": "http://localhost/releases-namespace/releases-project/-/commit/b83d6e391c22777fca1ed3012fce84f633d7fed0", + "createdAt": 2018-12-03T00:00:00.000Z, "descriptionHtml": "<p data-sourcepos=\\"1:1-1:33\\" dir=\\"auto\\">Best. Release. <strong>Ever.</strong> <gl-emoji title=\\"rocket\\" data-name=\\"rocket\\" data-unicode-version=\\"6.0\\">🚀</gl-emoji></p>", "evidences": Array [ Object { diff --git a/spec/frontend/releases/components/release_block_footer_spec.js b/spec/frontend/releases/components/release_block_footer_spec.js index 8f4efad197f..19b41d05a44 100644 --- a/spec/frontend/releases/components/release_block_footer_spec.js +++ b/spec/frontend/releases/components/release_block_footer_spec.js @@ -4,6 +4,7 @@ import { cloneDeep } from 'lodash'; import { nextTick } from 'vue'; import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; import { convertOneReleaseGraphQLResponse } from '~/releases/util'; +import { RELEASED_AT_ASC, RELEASED_AT_DESC, CREATED_ASC, CREATED_DESC } from '~/releases/constants'; import { trimText } from 'helpers/text_helper'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; @@ -43,88 +44,118 @@ describe('Release block footer', () => { const tagInfoSectionLink = () => tagInfoSection().findComponent(GlLink); const authorDateInfoSection = () => wrapper.find('.js-author-date-info'); - describe('with all props provided', () => { - beforeEach(() => factory()); - - it('renders the commit icon', () => { - const commitIcon = commitInfoSection().findComponent(GlIcon); - - expect(commitIcon.exists()).toBe(true); - expect(commitIcon.props('name')).toBe('commit'); - }); - - it('renders the commit SHA with a link', () => { - const commitLink = commitInfoSectionLink(); - - expect(commitLink.exists()).toBe(true); - expect(commitLink.text()).toBe(release.commit.shortId); - expect(commitLink.attributes('href')).toBe(release.commitPath); - }); - - it('renders the tag icon', () => { - const commitIcon = tagInfoSection().findComponent(GlIcon); - - expect(commitIcon.exists()).toBe(true); - expect(commitIcon.props('name')).toBe('tag'); - }); - - it('renders the tag name with a link', () => { - const commitLink = tagInfoSection().findComponent(GlLink); - - expect(commitLink.exists()).toBe(true); - expect(commitLink.text()).toBe(release.tagName); - expect(commitLink.attributes('href')).toBe(release.tagPath); - }); - - it('renders the author and creation time info', () => { - expect(trimText(authorDateInfoSection().text())).toBe( - `Created 1 year ago by ${release.author.username}`, - ); - }); - - describe('when the release date is in the past', () => { - it('prefixes the creation info with "Created"', () => { - expect(trimText(authorDateInfoSection().text())).toEqual(expect.stringMatching(/^Created/)); - }); - }); - - describe('renders the author and creation time info with future release date', () => { - beforeEach(() => { - factory({ releasedAt: mockFutureDate }); - }); - - it('renders the release date without the author name', () => { - expect(trimText(authorDateInfoSection().text())).toBe( - `Will be created in 1 month by ${release.author.username}`, - ); - }); - }); - - describe('when the release date is in the future', () => { - beforeEach(() => { - factory({ releasedAt: mockFutureDate }); - }); - - it('prefixes the creation info with "Will be created"', () => { - expect(trimText(authorDateInfoSection().text())).toEqual( - expect.stringMatching(/^Will be created/), - ); - }); - }); - - it("renders the author's avatar image", () => { - const avatarImg = authorDateInfoSection().find('img'); - - expect(avatarImg.exists()).toBe(true); - expect(avatarImg.attributes('src')).toBe(release.author.avatarUrl); - }); - - it("renders a link to the author's profile", () => { - const authorLink = authorDateInfoSection().findComponent(GlLink); - - expect(authorLink.exists()).toBe(true); - expect(authorLink.attributes('href')).toBe(release.author.webUrl); - }); + describe.each` + sortFlag | expectedInfoString + ${null} | ${'Created'} + ${CREATED_ASC} | ${'Created'} + ${CREATED_DESC} | ${'Created'} + ${RELEASED_AT_ASC} | ${'Released'} + ${RELEASED_AT_DESC} | ${'Released'} + `('with sorting set to $sortFlag', ({ sortFlag, expectedInfoString }) => { + const dateAt = + expectedInfoString === 'Created' ? originalRelease.createdAt : originalRelease.releasedAt; + + describe.each` + dateType | dateFlag | expectedInfoStringPrefix | expectedDateString + ${'empty'} | ${undefined} | ${null} | ${null} + ${'in the past'} | ${dateAt} | ${null} | ${'1 year ago'} + ${'in the future'} | ${mockFutureDate} | ${'Will be'} | ${'in 1 month'} + `( + 'with date set to $dateType', + ({ dateFlag, expectedInfoStringPrefix, expectedDateString }) => { + describe.each` + authorType | authorFlag | expectedAuthorString + ${'empty'} | ${undefined} | ${null} + ${'present'} | ${originalRelease.author} | ${'by user1'} + `('with author set to $authorType', ({ authorFlag, expectedAuthorString }) => { + const propsData = { sort: sortFlag, author: authorFlag }; + if (dateFlag !== '') { + propsData.createdAt = dateFlag; + propsData.releasedAt = dateFlag; + } + + beforeEach(() => { + factory({ ...propsData }); + }); + + const expectedString = [ + expectedInfoStringPrefix, + expectedInfoStringPrefix ? expectedInfoString.toLowerCase() : expectedInfoString, + expectedDateString, + expectedAuthorString, + ]; + + if (authorFlag || dateFlag) { + it('renders the author and creation time info', () => { + expect(trimText(authorDateInfoSection().text())).toBe( + expectedString.filter((n) => n).join(' '), + ); + }); + if (authorFlag) { + it("renders the author's avatar image", () => { + const avatarImg = authorDateInfoSection().find('img'); + + expect(avatarImg.exists()).toBe(true); + expect(avatarImg.attributes('src')).toBe(release.author.avatarUrl); + }); + + it("renders a link to the author's profile", () => { + const authorLink = authorDateInfoSection().findComponent(GlLink); + + expect(authorLink.exists()).toBe(true); + expect(authorLink.attributes('href')).toBe(release.author.webUrl); + }); + } else { + it("does not render the author's avatar image", () => { + const avatarImg = authorDateInfoSection().find('img'); + + expect(avatarImg.exists()).toBe(false); + }); + + it("does not render a link to the author's profile", () => { + const authorLink = authorDateInfoSection().findComponent(GlLink); + + expect(authorLink.exists()).toBe(false); + }); + } + } else { + it('does not render the author and creation time info', () => { + expect(authorDateInfoSection().exists()).toBe(false); + }); + } + + it('renders the commit icon', () => { + const commitIcon = commitInfoSection().findComponent(GlIcon); + + expect(commitIcon.exists()).toBe(true); + expect(commitIcon.props('name')).toBe('commit'); + }); + + it('renders the commit SHA with a link', () => { + const commitLink = commitInfoSectionLink(); + + expect(commitLink.exists()).toBe(true); + expect(commitLink.text()).toBe(release.commit.shortId); + expect(commitLink.attributes('href')).toBe(release.commitPath); + }); + + it('renders the tag icon', () => { + const commitIcon = tagInfoSection().findComponent(GlIcon); + + expect(commitIcon.exists()).toBe(true); + expect(commitIcon.props('name')).toBe('tag'); + }); + + it('renders the tag name with a link', () => { + const commitLink = tagInfoSection().findComponent(GlLink); + + expect(commitLink.exists()).toBe(true); + expect(commitLink.text()).toBe(release.tagName); + expect(commitLink.attributes('href')).toBe(release.tagPath); + }); + }); + }, + ); }); describe('without any commit info', () => { @@ -160,40 +191,4 @@ describe('Release block footer', () => { expect(tagInfoSection().text()).toBe(release.tagName); }); }); - - describe('without any author info', () => { - beforeEach(() => factory({ author: undefined })); - - it('renders the release date without the author name', () => { - expect(trimText(authorDateInfoSection().text())).toBe(`Created 1 year ago`); - }); - }); - - describe('future release without any author info', () => { - beforeEach(() => { - factory({ author: undefined, releasedAt: mockFutureDate }); - }); - - it('renders the release date without the author name', () => { - expect(trimText(authorDateInfoSection().text())).toBe(`Will be created in 1 month`); - }); - }); - - describe('without a released at date', () => { - beforeEach(() => factory({ releasedAt: undefined })); - - it('renders the author name without the release date', () => { - expect(trimText(authorDateInfoSection().text())).toBe( - `Created by ${release.author.username}`, - ); - }); - }); - - describe('without a release date or author info', () => { - beforeEach(() => factory({ author: undefined, releasedAt: undefined })); - - it('does not render any author or release date info', () => { - expect(authorDateInfoSection().exists()).toBe(false); - }); - }); }); diff --git a/spec/frontend/releases/components/release_block_spec.js b/spec/frontend/releases/components/release_block_spec.js index 096c3db8902..f1b8554fbc3 100644 --- a/spec/frontend/releases/components/release_block_spec.js +++ b/spec/frontend/releases/components/release_block_spec.js @@ -1,5 +1,4 @@ import { mount } from '@vue/test-utils'; -import $ from 'jquery'; import { nextTick } from 'vue'; import originalOneReleaseQueryResponse from 'test_fixtures/graphql/releases/graphql/queries/one_release.query.graphql.json'; import { convertOneReleaseGraphQLResponse } from '~/releases/util'; @@ -10,6 +9,9 @@ import ReleaseBlock from '~/releases/components/release_block.vue'; import ReleaseBlockFooter from '~/releases/components/release_block_footer.vue'; import { BACK_URL_PARAM } from '~/releases/constants'; import timeagoMixin from '~/vue_shared/mixins/timeago'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +jest.mock('~/behaviors/markdown/render_gfm'); describe('Release block', () => { let wrapper; @@ -34,7 +36,6 @@ describe('Release block', () => { const editButton = () => wrapper.find('.js-edit-button'); beforeEach(() => { - jest.spyOn($.fn, 'renderGFM'); release = convertOneReleaseGraphQLResponse(originalOneReleaseQueryResponse).data; }); @@ -62,7 +63,7 @@ describe('Release block', () => { it('renders release description', () => { expect(wrapper.vm.$refs['gfm-content']).toBeDefined(); - expect($.fn.renderGFM).toHaveBeenCalledTimes(1); + expect(renderGFM).toHaveBeenCalledTimes(1); }); it('renders release date', () => { diff --git a/spec/frontend/repository/components/table/index_spec.js b/spec/frontend/repository/components/table/index_spec.js index 2180f78a8df..8b987551b33 100644 --- a/spec/frontend/repository/components/table/index_spec.js +++ b/spec/frontend/repository/components/table/index_spec.js @@ -82,9 +82,6 @@ function factory({ path, isLoading = false, hasMore = true, entries = {}, commit mocks: { $apollo, }, - provide: { - glFeatures: { lazyLoadCommits: true }, - }, }); } diff --git a/spec/frontend/repository/components/table/row_spec.js b/spec/frontend/repository/components/table/row_spec.js index 64aa6d179a8..5d9138ab9cd 100644 --- a/spec/frontend/repository/components/table/row_spec.js +++ b/spec/frontend/repository/components/table/row_spec.js @@ -30,9 +30,6 @@ function factory(propsData = {}) { directives: { GlHoverLoad: createMockDirective(), }, - provide: { - glFeatures: { lazyLoadCommits: true }, - }, mocks: { $router, }, diff --git a/spec/frontend/repository/components/tree_content_spec.js b/spec/frontend/repository/components/tree_content_spec.js index 352f4314232..6eea66f1a7d 100644 --- a/spec/frontend/repository/components/tree_content_spec.js +++ b/spec/frontend/repository/components/tree_content_spec.js @@ -31,7 +31,6 @@ function factory(path, data = () => ({})) { glFeatures: { increasePageSizeExponentially: true, paginatedTreeGraphqlQuery: true, - lazyLoadCommits: true, }, }, }); diff --git a/spec/frontend/repository/utils/ref_switcher_utils_spec.js b/spec/frontend/repository/utils/ref_switcher_utils_spec.js new file mode 100644 index 00000000000..3335059554f --- /dev/null +++ b/spec/frontend/repository/utils/ref_switcher_utils_spec.js @@ -0,0 +1,22 @@ +import { generateRefDestinationPath } from '~/repository/utils/ref_switcher_utils'; +import setWindowLocation from 'helpers/set_window_location_helper'; + +const projectRootPath = 'root/Project1'; +const currentRef = 'main'; +const selectedRef = 'feature'; + +describe('generateRefDestinationPath', () => { + it.each` + currentPath | result + ${projectRootPath} | ${`${projectRootPath}/-/tree/${selectedRef}`} + ${`${projectRootPath}/-/tree/${currentRef}/dir1`} | ${`${projectRootPath}/-/tree/${selectedRef}/dir1`} + ${`${projectRootPath}/-/tree/${currentRef}/dir1/dir2`} | ${`${projectRootPath}/-/tree/${selectedRef}/dir1/dir2`} + ${`${projectRootPath}/-/blob/${currentRef}/test.js`} | ${`${projectRootPath}/-/blob/${selectedRef}/test.js`} + ${`${projectRootPath}/-/blob/${currentRef}/dir1/test.js`} | ${`${projectRootPath}/-/blob/${selectedRef}/dir1/test.js`} + ${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js`} | ${`${projectRootPath}/-/blob/${selectedRef}/dir1/dir2/test.js`} + ${`${projectRootPath}/-/blob/${currentRef}/dir1/dir2/test.js#L123`} | ${`${projectRootPath}/-/blob/${selectedRef}/dir1/dir2/test.js#L123`} + `('generates the correct destination path for $currentPath', ({ currentPath, result }) => { + setWindowLocation(currentPath); + expect(generateRefDestinationPath(projectRootPath, selectedRef)).toBe(result); + }); +}); diff --git a/spec/frontend/search/mock_data.js b/spec/frontend/search/mock_data.js index fa5ccfeb478..e02d3b0eab8 100644 --- a/spec/frontend/search/mock_data.js +++ b/spec/frontend/search/mock_data.js @@ -114,6 +114,7 @@ export const MOCK_NAVIGATION = { scope: 'projects', link: '/search?scope=projects&search=et', count_link: '/search/count?scope=projects&search=et', + count: '10,000+', }, blobs: { label: 'Code', diff --git a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js index c57eabd57b9..d5ecca4636c 100644 --- a/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js +++ b/spec/frontend/search/sidebar/components/confidentiality_filter_spec.js @@ -1,39 +1,16 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { MOCK_QUERY } from 'jest/search/mock_data'; import ConfidentialityFilter from '~/search/sidebar/components/confidentiality_filter.vue'; import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; -Vue.use(Vuex); - describe('ConfidentialityFilter', () => { let wrapper; - const actionSpies = { - applyQuery: jest.fn(), - resetQuery: jest.fn(), - }; - - const createComponent = (initialState) => { - const store = new Vuex.Store({ - state: { - query: MOCK_QUERY, - ...initialState, - }, - actions: actionSpies, - }); - + const createComponent = (initProps) => { wrapper = shallowMount(ConfidentialityFilter, { - store, + ...initProps, }); }; - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - const findRadioFilter = () => wrapper.findComponent(RadioFilter); describe('template', () => { @@ -41,24 +18,28 @@ describe('ConfidentialityFilter', () => { createComponent(); }); - describe.each` - scope | showFilter - ${'issues'} | ${true} - ${'merge_requests'} | ${false} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`dropdown`, ({ scope, showFilter }) => { - beforeEach(() => { - createComponent({ query: { scope } }); - }); + it('renders the component', () => { + expect(findRadioFilter().exists()).toBe(true); + }); + }); - it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findRadioFilter().exists()).toBe(showFilter); + describe.each` + hasFeatureFlagEnabled | paddingClass + ${true} | ${'gl-px-5'} + ${false} | ${'gl-px-0'} + `(`RadioFilter`, ({ hasFeatureFlagEnabled, paddingClass }) => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + searchPageVerticalNav: hasFeatureFlagEnabled, + }, + }, }); }); + + it(`has ${paddingClass} class`, () => { + expect(findRadioFilter().classes(paddingClass)).toBe(true); + }); }); }); diff --git a/spec/frontend/search/sidebar/components/filters_spec.js b/spec/frontend/search/sidebar/components/filters_spec.js index 4f217709297..7e564bfa005 100644 --- a/spec/frontend/search/sidebar/components/filters_spec.js +++ b/spec/frontend/search/sidebar/components/filters_spec.js @@ -129,4 +129,44 @@ describe('GlobalSearchSidebarFilters', () => { expect(actionSpies.resetQuery).toHaveBeenCalled(); }); }); + + describe.each` + scope | showFilter + ${'issues'} | ${true} + ${'merge_requests'} | ${false} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`ConfidentialityFilter`, ({ scope, showFilter }) => { + beforeEach(() => { + createComponent({ urlQuery: { scope } }); + }); + + it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findConfidentialityFilter().exists()).toBe(showFilter); + }); + }); + + describe.each` + scope | showFilter + ${'issues'} | ${true} + ${'merge_requests'} | ${true} + ${'projects'} | ${false} + ${'milestones'} | ${false} + ${'users'} | ${false} + ${'notes'} | ${false} + ${'wiki_blobs'} | ${false} + ${'blobs'} | ${false} + `(`StatusFilter`, ({ scope, showFilter }) => { + beforeEach(() => { + createComponent({ urlQuery: { scope } }); + }); + + it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => { + expect(findStatusFilter().exists()).toBe(showFilter); + }); + }); }); diff --git a/spec/frontend/search/sidebar/components/scope_navigation_spec.js b/spec/frontend/search/sidebar/components/scope_navigation_spec.js index 6262a52e01a..23c158239dc 100644 --- a/spec/frontend/search/sidebar/components/scope_navigation_spec.js +++ b/spec/frontend/search/sidebar/components/scope_navigation_spec.js @@ -1,4 +1,4 @@ -import { GlNav, GlNavItem } from '@gitlab/ui'; +import { GlNav, GlNavItem, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -37,6 +37,7 @@ describe('ScopeNavigation', () => { const findGlNav = () => wrapper.findComponent(GlNav); const findGlNavItems = () => wrapper.findAllComponents(GlNavItem); const findGlNavItemActive = () => findGlNavItems().wrappers.filter((w) => w.attributes('active')); + const findGlNavItemActiveLabel = () => findGlNavItemActive().at(0).findAll('span').at(0).text(); const findGlNavItemActiveCount = () => findGlNavItemActive().at(0).findAll('span').at(1); describe('scope navigation', () => { @@ -56,7 +57,7 @@ describe('ScopeNavigation', () => { expect(findGlNavItems()).toHaveLength(9); }); - it('nav items have proper links', () => { + it('has all proper links', () => { const linkAtPosition = 3; const { link } = MOCK_NAVIGATION[Object.keys(MOCK_NAVIGATION)[linkAtPosition]]; @@ -64,17 +65,47 @@ describe('ScopeNavigation', () => { }); }); - describe('scope navigation sets proper state', () => { + describe('scope navigation sets proper state with url scope set', () => { beforeEach(() => { createComponent(); }); - it('sets proper class to active item', () => { + it('has correct active item', () => { expect(findGlNavItemActive()).toHaveLength(1); + expect(findGlNavItemActiveLabel()).toBe('Issues'); }); - it('active item', () => { + it('has correct active item count', () => { expect(findGlNavItemActiveCount().text()).toBe('2.4K'); }); + + it('does not have plus sign after count text', () => { + expect(findGlNavItemActive().at(0).findComponent(GlIcon).exists()).toBe(false); + }); + + it('has count is highlighted correctly', () => { + expect(findGlNavItemActiveCount().classes('gl-text-gray-900')).toBe(true); + }); + }); + + describe('scope navigation sets proper state with NO url scope set', () => { + beforeEach(() => { + createComponent({ + urlQuery: {}, + }); + }); + + it('has correct active item', () => { + expect(findGlNavItems().at(0).attributes('active')).toBe('true'); + expect(findGlNavItemActiveLabel()).toBe('Projects'); + }); + + it('has correct active item count', () => { + expect(findGlNavItemActiveCount().text()).toBe('10K'); + }); + + it('has correct active item count and over limit sign', () => { + expect(findGlNavItemActive().at(0).findComponent(GlIcon).exists()).toBe(true); + }); }); }); diff --git a/spec/frontend/search/sidebar/components/status_filter_spec.js b/spec/frontend/search/sidebar/components/status_filter_spec.js index f3152c014b6..2ed199469e6 100644 --- a/spec/frontend/search/sidebar/components/status_filter_spec.js +++ b/spec/frontend/search/sidebar/components/status_filter_spec.js @@ -1,39 +1,16 @@ import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; -import Vuex from 'vuex'; -import { MOCK_QUERY } from 'jest/search/mock_data'; import RadioFilter from '~/search/sidebar/components/radio_filter.vue'; import StatusFilter from '~/search/sidebar/components/status_filter.vue'; -Vue.use(Vuex); - describe('StatusFilter', () => { let wrapper; - const actionSpies = { - applyQuery: jest.fn(), - resetQuery: jest.fn(), - }; - - const createComponent = (initialState) => { - const store = new Vuex.Store({ - state: { - query: MOCK_QUERY, - ...initialState, - }, - actions: actionSpies, - }); - + const createComponent = (initProps) => { wrapper = shallowMount(StatusFilter, { - store, + ...initProps, }); }; - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - const findRadioFilter = () => wrapper.findComponent(RadioFilter); describe('template', () => { @@ -41,24 +18,28 @@ describe('StatusFilter', () => { createComponent(); }); - describe.each` - scope | showFilter - ${'issues'} | ${true} - ${'merge_requests'} | ${true} - ${'projects'} | ${false} - ${'milestones'} | ${false} - ${'users'} | ${false} - ${'notes'} | ${false} - ${'wiki_blobs'} | ${false} - ${'blobs'} | ${false} - `(`dropdown`, ({ scope, showFilter }) => { - beforeEach(() => { - createComponent({ query: { scope } }); - }); + it('renders the component', () => { + expect(findRadioFilter().exists()).toBe(true); + }); + }); - it(`does${showFilter ? '' : ' not'} render when scope is ${scope}`, () => { - expect(findRadioFilter().exists()).toBe(showFilter); + describe.each` + hasFeatureFlagEnabled | paddingClass + ${true} | ${'gl-px-5'} + ${false} | ${'gl-px-0'} + `(`RadioFilter`, ({ hasFeatureFlagEnabled, paddingClass }) => { + beforeEach(() => { + createComponent({ + provide: { + glFeatures: { + searchPageVerticalNav: hasFeatureFlagEnabled, + }, + }, }); }); + + it(`has ${paddingClass} class`, () => { + expect(findRadioFilter().classes(paddingClass)).toBe(true); + }); }); }); diff --git a/spec/frontend/search/topbar/components/app_spec.js b/spec/frontend/search/topbar/components/app_spec.js index c7fd7084101..3975887cfff 100644 --- a/spec/frontend/search/topbar/components/app_spec.js +++ b/spec/frontend/search/topbar/components/app_spec.js @@ -1,4 +1,4 @@ -import { GlSearchBoxByClick } from '@gitlab/ui'; +import { GlSearchBoxByClick, GlButton } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; @@ -6,6 +6,8 @@ import { MOCK_QUERY } from 'jest/search/mock_data'; import GlobalSearchTopbar from '~/search/topbar/components/app.vue'; import GroupFilter from '~/search/topbar/components/group_filter.vue'; import ProjectFilter from '~/search/topbar/components/project_filter.vue'; +import MarkdownDrawer from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; +import { SYNTAX_OPTIONS_DOCUMENT } from '~/search/topbar/constants'; Vue.use(Vuex); @@ -18,7 +20,7 @@ describe('GlobalSearchTopbar', () => { preloadStoredFrequentItems: jest.fn(), }; - const createComponent = (initialState) => { + const createComponent = (initialState, props, stubs) => { const store = new Vuex.Store({ state: { query: MOCK_QUERY, @@ -29,6 +31,8 @@ describe('GlobalSearchTopbar', () => { wrapper = shallowMount(GlobalSearchTopbar, { store, + propsData: props, + stubs, }); }; @@ -39,6 +43,8 @@ describe('GlobalSearchTopbar', () => { const findGlSearchBox = () => wrapper.findComponent(GlSearchBoxByClick); const findGroupFilter = () => wrapper.findComponent(GroupFilter); const findProjectFilter = () => wrapper.findComponent(ProjectFilter); + const findSyntaxOptionButton = () => wrapper.findComponent(GlButton); + const findSyntaxOptionDrawer = () => wrapper.findComponent(MarkdownDrawer); describe('template', () => { beforeEach(() => { @@ -71,6 +77,72 @@ describe('GlobalSearchTopbar', () => { expect(findProjectFilter().exists()).toBe(showFilters); }); }); + + describe('syntax option feature', () => { + describe('template', () => { + beforeEach(() => { + createComponent( + { query: { repository_ref: '' } }, + { elasticsearchEnabled: true, defaultBranchName: '' }, + ); + }); + + it('renders button correctly', () => { + expect(findSyntaxOptionButton().exists()).toBe(true); + }); + + it('renders drawer correctly', () => { + expect(findSyntaxOptionDrawer().exists()).toBe(true); + expect(findSyntaxOptionDrawer().attributes('documentpath')).toBe(SYNTAX_OPTIONS_DOCUMENT); + }); + + it('dispatched correct click action', () => { + const draweToggleSpy = jest.fn(); + wrapper.vm.$refs.markdownDrawer.toggleDrawer = draweToggleSpy; + + findSyntaxOptionButton().vm.$emit('click'); + expect(draweToggleSpy).toHaveBeenCalled(); + }); + }); + + describe.each` + query | propsData | hasSyntaxOptions + ${null} | ${{ elasticsearchEnabled: false, defaultBranchName: '' }} | ${false} + ${{ query: { repository_ref: '' } }} | ${{ elasticsearchEnabled: false, defaultBranchName: '' }} | ${false} + ${{ query: { repository_ref: 'master' } }} | ${{ elasticsearchEnabled: false, defaultBranchName: 'master' }} | ${false} + ${{ query: { repository_ref: 'master' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: '' }} | ${false} + ${{ query: { repository_ref: '' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: 'master' }} | ${true} + ${{ query: { repository_ref: '' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: '' }} | ${true} + ${{ query: { repository_ref: 'master' } }} | ${{ elasticsearchEnabled: true, defaultBranchName: 'master' }} | ${true} + `( + 'renders the syntax option based on component state', + ({ query, propsData, hasSyntaxOptions }) => { + beforeEach(() => { + createComponent(query, { ...propsData }); + }); + + it(`does${ + hasSyntaxOptions ? '' : ' not' + } have syntax option button when repository_ref: '${ + query?.query?.repository_ref + }', elasticsearchEnabled: ${propsData.elasticsearchEnabled}, defaultBranchName: '${ + propsData.defaultBranchName + }'`, () => { + expect(findSyntaxOptionButton().exists()).toBe(hasSyntaxOptions); + }); + + it(`does${ + hasSyntaxOptions ? '' : ' not' + } have syntax option drawer when repository_ref: '${ + query?.query?.repository_ref + }', elasticsearchEnabled: ${propsData.elasticsearchEnabled}, defaultBranchName: '${ + propsData.defaultBranchName + }'`, () => { + expect(findSyntaxOptionDrawer().exists()).toBe(hasSyntaxOptions); + }); + }, + ); + }); }); describe('actions', () => { diff --git a/spec/frontend/self_monitor/store/actions_spec.js b/spec/frontend/self_monitor/store/actions_spec.js index 21e63533c66..65c9d2f5f01 100644 --- a/spec/frontend/self_monitor/store/actions_spec.js +++ b/spec/frontend/self_monitor/store/actions_spec.js @@ -1,7 +1,7 @@ import axios from 'axios'; import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; -import statusCodes from '~/lib/utils/http_status'; +import statusCodes, { HTTP_STATUS_ACCEPTED } from '~/lib/utils/http_status'; import * as actions from '~/self_monitor/store/actions'; import * as types from '~/self_monitor/store/mutation_types'; import createState from '~/self_monitor/store/state'; @@ -44,7 +44,7 @@ describe('self-monitor actions', () => { beforeEach(() => { state.createProjectEndpoint = '/create'; state.createProjectStatusEndpoint = '/create_status'; - mock.onPost(state.createProjectEndpoint).reply(statusCodes.ACCEPTED, { + mock.onPost(state.createProjectEndpoint).reply(HTTP_STATUS_ACCEPTED, { job_id: '123', }); mock.onGet(state.createProjectStatusEndpoint).reply(statusCodes.OK, { @@ -151,7 +151,7 @@ describe('self-monitor actions', () => { beforeEach(() => { state.deleteProjectEndpoint = '/delete'; state.deleteProjectStatusEndpoint = '/delete-status'; - mock.onDelete(state.deleteProjectEndpoint).reply(statusCodes.ACCEPTED, { + mock.onDelete(state.deleteProjectEndpoint).reply(HTTP_STATUS_ACCEPTED, { job_id: '456', }); mock.onGet(state.deleteProjectStatusEndpoint).reply(statusCodes.OK, { diff --git a/spec/frontend/sentry/index_spec.js b/spec/frontend/sentry/index_spec.js index d1f098112e8..2dd528a8a1c 100644 --- a/spec/frontend/sentry/index_spec.js +++ b/spec/frontend/sentry/index_spec.js @@ -1,17 +1,20 @@ import index from '~/sentry/index'; + +import LegacySentryConfig from '~/sentry/legacy_sentry_config'; import SentryConfig from '~/sentry/sentry_config'; -describe('SentryConfig options', () => { +describe('Sentry init', () => { + let originalGon; + const dsn = 'https://123@sentry.gitlab.test/123'; - const currentUserId = 'currentUserId'; - const gitlabUrl = 'gitlabUrl'; const environment = 'test'; + const currentUserId = '1'; + const gitlabUrl = 'gitlabUrl'; const revision = 'revision'; const featureCategory = 'my_feature_category'; - let indexReturnValue; - beforeEach(() => { + originalGon = window.gon; window.gon = { sentry_dsn: dsn, sentry_environment: environment, @@ -21,28 +24,41 @@ describe('SentryConfig options', () => { feature_category: featureCategory, }; - process.env.HEAD_COMMIT_SHA = revision; - + jest.spyOn(LegacySentryConfig, 'init').mockImplementation(); jest.spyOn(SentryConfig, 'init').mockImplementation(); + }); - indexReturnValue = index(); + afterEach(() => { + window.gon = originalGon; }); - it('should init with .sentryDsn, .currentUserId, .whitelistUrls and environment', () => { - expect(SentryConfig.init).toHaveBeenCalledWith({ - dsn, - currentUserId, - whitelistUrls: [gitlabUrl, 'webpack-internal://'], - environment, - release: revision, - tags: { - revision, - feature_category: featureCategory, - }, - }); + it('exports new version of Sentry in the global object', () => { + // eslint-disable-next-line no-underscore-dangle + expect(window._Sentry.SDK_VERSION).not.toMatch(/^5\./); }); - it('should return SentryConfig', () => { - expect(indexReturnValue).toBe(SentryConfig); + describe('when called', () => { + beforeEach(() => { + index(); + }); + + it('configures sentry', () => { + expect(SentryConfig.init).toHaveBeenCalledTimes(1); + expect(SentryConfig.init).toHaveBeenCalledWith({ + dsn, + currentUserId, + allowUrls: [gitlabUrl, 'webpack-internal://'], + environment, + release: revision, + tags: { + revision, + feature_category: featureCategory, + }, + }); + }); + + it('does not configure legacy sentry', () => { + expect(LegacySentryConfig.init).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/sentry/legacy_index_spec.js b/spec/frontend/sentry/legacy_index_spec.js new file mode 100644 index 00000000000..5c336f8392e --- /dev/null +++ b/spec/frontend/sentry/legacy_index_spec.js @@ -0,0 +1,64 @@ +import index from '~/sentry/legacy_index'; + +import LegacySentryConfig from '~/sentry/legacy_sentry_config'; +import SentryConfig from '~/sentry/sentry_config'; + +describe('Sentry init', () => { + let originalGon; + + const dsn = 'https://123@sentry.gitlab.test/123'; + const environment = 'test'; + const currentUserId = '1'; + const gitlabUrl = 'gitlabUrl'; + const revision = 'revision'; + const featureCategory = 'my_feature_category'; + + beforeEach(() => { + originalGon = window.gon; + window.gon = { + sentry_dsn: dsn, + sentry_environment: environment, + current_user_id: currentUserId, + gitlab_url: gitlabUrl, + revision, + feature_category: featureCategory, + }; + + jest.spyOn(LegacySentryConfig, 'init').mockImplementation(); + jest.spyOn(SentryConfig, 'init').mockImplementation(); + }); + + afterEach(() => { + window.gon = originalGon; + }); + + it('exports legacy version of Sentry in the global object', () => { + // eslint-disable-next-line no-underscore-dangle + expect(window._Sentry.SDK_VERSION).toMatch(/^5\./); + }); + + describe('when called', () => { + beforeEach(() => { + index(); + }); + + it('configures legacy sentry', () => { + expect(LegacySentryConfig.init).toHaveBeenCalledTimes(1); + expect(LegacySentryConfig.init).toHaveBeenCalledWith({ + dsn, + currentUserId, + whitelistUrls: [gitlabUrl, 'webpack-internal://'], + environment, + release: revision, + tags: { + revision, + feature_category: featureCategory, + }, + }); + }); + + it('does not configure new sentry', () => { + expect(SentryConfig.init).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/spec/frontend/sentry/legacy_sentry_config_spec.js b/spec/frontend/sentry/legacy_sentry_config_spec.js new file mode 100644 index 00000000000..fe90cb49074 --- /dev/null +++ b/spec/frontend/sentry/legacy_sentry_config_spec.js @@ -0,0 +1,215 @@ +import * as Sentry5 from 'sentrybrowser5'; +import LegacySentryConfig from '~/sentry/legacy_sentry_config'; + +describe('LegacySentryConfig', () => { + describe('IGNORE_ERRORS', () => { + it('should be an array of strings', () => { + const areStrings = LegacySentryConfig.IGNORE_ERRORS.every( + (error) => typeof error === 'string', + ); + + expect(areStrings).toBe(true); + }); + }); + + describe('BLACKLIST_URLS', () => { + it('should be an array of regexps', () => { + const areRegExps = LegacySentryConfig.BLACKLIST_URLS.every((url) => url instanceof RegExp); + + expect(areRegExps).toBe(true); + }); + }); + + describe('SAMPLE_RATE', () => { + it('should be a finite number', () => { + expect(typeof LegacySentryConfig.SAMPLE_RATE).toEqual('number'); + }); + }); + + describe('init', () => { + const options = { + currentUserId: 1, + }; + + beforeEach(() => { + jest.spyOn(LegacySentryConfig, 'configure'); + jest.spyOn(LegacySentryConfig, 'bindSentryErrors'); + jest.spyOn(LegacySentryConfig, 'setUser'); + + LegacySentryConfig.init(options); + }); + + it('should set the options property', () => { + expect(LegacySentryConfig.options).toEqual(options); + }); + + it('should call the configure method', () => { + expect(LegacySentryConfig.configure).toHaveBeenCalled(); + }); + + it('should call the error bindings method', () => { + expect(LegacySentryConfig.bindSentryErrors).toHaveBeenCalled(); + }); + + it('should call setUser', () => { + expect(LegacySentryConfig.setUser).toHaveBeenCalled(); + }); + + it('should not call setUser if there is no current user ID', () => { + LegacySentryConfig.setUser.mockClear(); + options.currentUserId = undefined; + + LegacySentryConfig.init(options); + + expect(LegacySentryConfig.setUser).not.toHaveBeenCalled(); + }); + }); + + describe('configure', () => { + const sentryConfig = {}; + const options = { + dsn: 'https://123@sentry.gitlab.test/123', + whitelistUrls: ['//gitlabUrl', 'webpack-internal://'], + environment: 'test', + release: 'revision', + tags: { + revision: 'revision', + feature_category: 'my_feature_category', + }, + }; + + beforeEach(() => { + jest.spyOn(Sentry5, 'init').mockImplementation(); + jest.spyOn(Sentry5, 'setTags').mockImplementation(); + + sentryConfig.options = options; + sentryConfig.IGNORE_ERRORS = 'ignore_errors'; + sentryConfig.BLACKLIST_URLS = 'blacklist_urls'; + + LegacySentryConfig.configure.call(sentryConfig); + }); + + it('should call Sentry5.init', () => { + expect(Sentry5.init).toHaveBeenCalledWith({ + dsn: options.dsn, + release: options.release, + sampleRate: 0.95, + whitelistUrls: options.whitelistUrls, + environment: 'test', + ignoreErrors: sentryConfig.IGNORE_ERRORS, + blacklistUrls: sentryConfig.BLACKLIST_URLS, + }); + }); + + it('should call Sentry5.setTags', () => { + expect(Sentry5.setTags).toHaveBeenCalledWith(options.tags); + }); + + it('should set environment from options', () => { + sentryConfig.options.environment = 'development'; + + LegacySentryConfig.configure.call(sentryConfig); + + expect(Sentry5.init).toHaveBeenCalledWith({ + dsn: options.dsn, + release: options.release, + sampleRate: 0.95, + whitelistUrls: options.whitelistUrls, + environment: 'development', + ignoreErrors: sentryConfig.IGNORE_ERRORS, + blacklistUrls: sentryConfig.BLACKLIST_URLS, + }); + }); + }); + + describe('setUser', () => { + let sentryConfig; + + beforeEach(() => { + sentryConfig = { options: { currentUserId: 1 } }; + jest.spyOn(Sentry5, 'setUser'); + + LegacySentryConfig.setUser.call(sentryConfig); + }); + + it('should call .setUser', () => { + expect(Sentry5.setUser).toHaveBeenCalledWith({ + id: sentryConfig.options.currentUserId, + }); + }); + }); + + describe('handleSentryErrors', () => { + let event; + let req; + let config; + let err; + + beforeEach(() => { + event = {}; + req = { status: 'status', responseText: 'Unknown response text', statusText: 'statusText' }; + config = { type: 'type', url: 'url', data: 'data' }; + err = {}; + + jest.spyOn(Sentry5, 'captureMessage'); + + LegacySentryConfig.handleSentryErrors(event, req, config, err); + }); + + it('should call Sentry5.captureMessage', () => { + expect(Sentry5.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: err, + event, + }, + }); + }); + + describe('if no err is provided', () => { + beforeEach(() => { + LegacySentryConfig.handleSentryErrors(event, req, config); + }); + + it('should use req.statusText as the error value', () => { + expect(Sentry5.captureMessage).toHaveBeenCalledWith(req.statusText, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: req.responseText, + error: req.statusText, + event, + }, + }); + }); + }); + + describe('if no req.responseText is provided', () => { + beforeEach(() => { + req.responseText = undefined; + + LegacySentryConfig.handleSentryErrors(event, req, config, err); + }); + + it('should use `Unknown response text` as the response', () => { + expect(Sentry5.captureMessage).toHaveBeenCalledWith(err, { + extra: { + type: config.type, + url: config.url, + data: config.data, + status: req.status, + response: 'Unknown response text', + error: err, + event, + }, + }); + }); + }); + }); +}); diff --git a/spec/frontend/sentry/sentry_browser_wrapper_spec.js b/spec/frontend/sentry/sentry_browser_wrapper_spec.js new file mode 100644 index 00000000000..f4d646bab78 --- /dev/null +++ b/spec/frontend/sentry/sentry_browser_wrapper_spec.js @@ -0,0 +1,59 @@ +import * as Sentry from '~/sentry/sentry_browser_wrapper'; + +const mockError = new Error('error!'); +const mockMsg = 'msg!'; +const mockFn = () => {}; + +describe('SentryBrowserWrapper', () => { + afterEach(() => { + // eslint-disable-next-line no-underscore-dangle + delete window._Sentry; + }); + + describe('when _Sentry is not defined', () => { + it('methods fail silently', () => { + expect(() => { + Sentry.captureException(mockError); + Sentry.captureMessage(mockMsg); + Sentry.withScope(mockFn); + }).not.toThrow(); + }); + }); + + describe('when _Sentry is defined', () => { + let mockCaptureException; + let mockCaptureMessage; + let mockWithScope; + + beforeEach(async () => { + mockCaptureException = jest.fn(); + mockCaptureMessage = jest.fn(); + mockWithScope = jest.fn(); + + // eslint-disable-next-line no-underscore-dangle + window._Sentry = { + captureException: mockCaptureException, + captureMessage: mockCaptureMessage, + withScope: mockWithScope, + }; + }); + + it('captureException is called', () => { + Sentry.captureException(mockError); + + expect(mockCaptureException).toHaveBeenCalledWith(mockError); + }); + + it('captureMessage is called', () => { + Sentry.captureMessage(mockMsg); + + expect(mockCaptureMessage).toHaveBeenCalledWith(mockMsg); + }); + + it('withScope is called', () => { + Sentry.withScope(mockFn); + + expect(mockWithScope).toHaveBeenCalledWith(mockFn); + }); + }); +}); diff --git a/spec/frontend/sentry/sentry_config_spec.js b/spec/frontend/sentry/sentry_config_spec.js index 9f67b681b8d..44acbee9b38 100644 --- a/spec/frontend/sentry/sentry_config_spec.js +++ b/spec/frontend/sentry/sentry_config_spec.js @@ -1,29 +1,9 @@ -import * as Sentry from '@sentry/browser'; +import * as Sentry from 'sentrybrowser7'; +import { IGNORE_ERRORS, DENY_URLS, SAMPLE_RATE } from '~/sentry/constants'; + import SentryConfig from '~/sentry/sentry_config'; describe('SentryConfig', () => { - describe('IGNORE_ERRORS', () => { - it('should be an array of strings', () => { - const areStrings = SentryConfig.IGNORE_ERRORS.every((error) => typeof error === 'string'); - - expect(areStrings).toBe(true); - }); - }); - - describe('BLACKLIST_URLS', () => { - it('should be an array of regexps', () => { - const areRegExps = SentryConfig.BLACKLIST_URLS.every((url) => url instanceof RegExp); - - expect(areRegExps).toBe(true); - }); - }); - - describe('SAMPLE_RATE', () => { - it('should be a finite number', () => { - expect(typeof SentryConfig.SAMPLE_RATE).toEqual('number'); - }); - }); - describe('init', () => { const options = { currentUserId: 1, @@ -31,7 +11,6 @@ describe('SentryConfig', () => { beforeEach(() => { jest.spyOn(SentryConfig, 'configure'); - jest.spyOn(SentryConfig, 'bindSentryErrors'); jest.spyOn(SentryConfig, 'setUser'); SentryConfig.init(options); @@ -45,19 +24,13 @@ describe('SentryConfig', () => { expect(SentryConfig.configure).toHaveBeenCalled(); }); - it('should call the error bindings method', () => { - expect(SentryConfig.bindSentryErrors).toHaveBeenCalled(); - }); - it('should call setUser', () => { expect(SentryConfig.setUser).toHaveBeenCalled(); }); it('should not call setUser if there is no current user ID', () => { SentryConfig.setUser.mockClear(); - options.currentUserId = undefined; - - SentryConfig.init(options); + SentryConfig.init({ currentUserId: undefined }); expect(SentryConfig.setUser).not.toHaveBeenCalled(); }); @@ -67,7 +40,7 @@ describe('SentryConfig', () => { const sentryConfig = {}; const options = { dsn: 'https://123@sentry.gitlab.test/123', - whitelistUrls: ['//gitlabUrl', 'webpack-internal://'], + allowUrls: ['//gitlabUrl', 'webpack-internal://'], environment: 'test', release: 'revision', tags: { @@ -81,8 +54,6 @@ describe('SentryConfig', () => { jest.spyOn(Sentry, 'setTags').mockImplementation(); sentryConfig.options = options; - sentryConfig.IGNORE_ERRORS = 'ignore_errors'; - sentryConfig.BLACKLIST_URLS = 'blacklist_urls'; SentryConfig.configure.call(sentryConfig); }); @@ -91,11 +62,11 @@ describe('SentryConfig', () => { expect(Sentry.init).toHaveBeenCalledWith({ dsn: options.dsn, release: options.release, - sampleRate: 0.95, - whitelistUrls: options.whitelistUrls, - environment: 'test', - ignoreErrors: sentryConfig.IGNORE_ERRORS, - blacklistUrls: sentryConfig.BLACKLIST_URLS, + sampleRate: SAMPLE_RATE, + allowUrls: options.allowUrls, + environment: options.environment, + ignoreErrors: IGNORE_ERRORS, + denyUrls: DENY_URLS, }); }); @@ -111,11 +82,11 @@ describe('SentryConfig', () => { expect(Sentry.init).toHaveBeenCalledWith({ dsn: options.dsn, release: options.release, - sampleRate: 0.95, - whitelistUrls: options.whitelistUrls, + sampleRate: SAMPLE_RATE, + allowUrls: options.allowUrls, environment: 'development', - ignoreErrors: sentryConfig.IGNORE_ERRORS, - blacklistUrls: sentryConfig.BLACKLIST_URLS, + ignoreErrors: IGNORE_ERRORS, + denyUrls: DENY_URLS, }); }); }); @@ -136,78 +107,4 @@ describe('SentryConfig', () => { }); }); }); - - describe('handleSentryErrors', () => { - let event; - let req; - let config; - let err; - - beforeEach(() => { - event = {}; - req = { status: 'status', responseText: 'Unknown response text', statusText: 'statusText' }; - config = { type: 'type', url: 'url', data: 'data' }; - err = {}; - - jest.spyOn(Sentry, 'captureMessage'); - - SentryConfig.handleSentryErrors(event, req, config, err); - }); - - it('should call Sentry.captureMessage', () => { - expect(Sentry.captureMessage).toHaveBeenCalledWith(err, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: req.responseText, - error: err, - event, - }, - }); - }); - - describe('if no err is provided', () => { - beforeEach(() => { - SentryConfig.handleSentryErrors(event, req, config); - }); - - it('should use req.statusText as the error value', () => { - expect(Sentry.captureMessage).toHaveBeenCalledWith(req.statusText, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: req.responseText, - error: req.statusText, - event, - }, - }); - }); - }); - - describe('if no req.responseText is provided', () => { - beforeEach(() => { - req.responseText = undefined; - - SentryConfig.handleSentryErrors(event, req, config, err); - }); - - it('should use `Unknown response text` as the response', () => { - expect(Sentry.captureMessage).toHaveBeenCalledWith(err, { - extra: { - type: config.type, - url: config.url, - data: config.data, - status: req.status, - response: 'Unknown response text', - error: err, - event, - }, - }); - }); - }); - }); }); diff --git a/spec/frontend/sidebar/assignee_title_spec.js b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js index 14a6bdbf907..14a6bdbf907 100644 --- a/spec/frontend/sidebar/assignee_title_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignee_title_spec.js diff --git a/spec/frontend/sidebar/assignees_realtime_spec.js b/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js index ae8f07bf901..080171fb2ea 100644 --- a/spec/frontend/sidebar/assignees_realtime_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_realtime_spec.js @@ -6,12 +6,12 @@ import waitForPromises from 'helpers/wait_for_promises'; import AssigneesRealtime from '~/sidebar/components/assignees/assignees_realtime.vue'; import issuableAssigneesSubscription from '~/sidebar/queries/issuable_assignees.subscription.graphql'; import SidebarMediator from '~/sidebar/sidebar_mediator'; -import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; +import getIssueAssigneesQuery from '~/sidebar/queries/get_issue_assignees.query.graphql'; import Mock, { issuableQueryResponse, subscriptionNullResponse, subscriptionResponse, -} from './mock_data'; +} from '../../mock_data'; Vue.use(VueApollo); diff --git a/spec/frontend/sidebar/assignees_spec.js b/spec/frontend/sidebar/components/assignees/assignees_spec.js index 7cf7fd33022..6971ae2f9ed 100644 --- a/spec/frontend/sidebar/assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/assignees_spec.js @@ -5,7 +5,7 @@ import { trimText } from 'helpers/text_helper'; import UsersMockHelper from 'helpers/user_mock_data_helper'; import Assignee from '~/sidebar/components/assignees/assignees.vue'; import AssigneeAvatarLink from '~/sidebar/components/assignees/assignee_avatar_link.vue'; -import UsersMock from './mock_data'; +import UsersMock from '../../mock_data'; describe('Assignee component', () => { const getDefaultProps = () => ({ diff --git a/spec/frontend/sidebar/issuable_assignees_spec.js b/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js index 1161fefcc64..1161fefcc64 100644 --- a/spec/frontend/sidebar/issuable_assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/issuable_assignees_spec.js diff --git a/spec/frontend/sidebar/sidebar_assignees_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js index 2cb2425532b..58b174059fa 100644 --- a/spec/frontend/sidebar/sidebar_assignees_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_spec.js @@ -8,7 +8,7 @@ import SidebarAssignees from '~/sidebar/components/assignees/sidebar_assignees.v import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; +import Mock from '../../mock_data'; describe('sidebar assignees', () => { let wrapper; diff --git a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js index cbb4c41dd14..3aca346ff5f 100644 --- a/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js +++ b/spec/frontend/sidebar/components/assignees/sidebar_assignees_widget_spec.js @@ -12,8 +12,8 @@ import IssuableAssignees from '~/sidebar/components/assignees/issuable_assignees import SidebarAssigneesWidget from '~/sidebar/components/assignees/sidebar_assignees_widget.vue'; import SidebarInviteMembers from '~/sidebar/components/assignees/sidebar_invite_members.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import getIssueAssigneesQuery from '~/vue_shared/components/sidebar/queries/get_issue_assignees.query.graphql'; -import updateIssueAssigneesMutation from '~/vue_shared/components/sidebar/queries/update_issue_assignees.mutation.graphql'; +import getIssueAssigneesQuery from '~/sidebar/queries/get_issue_assignees.query.graphql'; +import updateIssueAssigneesMutation from '~/sidebar/queries/update_issue_assignees.mutation.graphql'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import { issuableQueryResponse, updateIssueAssigneesMutationResponse } from '../../mock_data'; diff --git a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js b/spec/frontend/sidebar/components/copy/copy_email_to_clipboard_spec.js index 69a8d645973..5b6db43a366 100644 --- a/spec/frontend/sidebar/components/copy_email_to_clipboard_spec.js +++ b/spec/frontend/sidebar/components/copy/copy_email_to_clipboard_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import CopyEmailToClipboard from '~/sidebar/components/copy_email_to_clipboard.vue'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyEmailToClipboard from '~/sidebar/components/copy/copy_email_to_clipboard.vue'; +import CopyableField from '~/sidebar/components/copy/copyable_field.vue'; describe('CopyEmailToClipboard component', () => { const mockIssueEmailAddress = 'sample+email@test.com'; diff --git a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js b/spec/frontend/sidebar/components/copy/copyable_field_spec.js index 3980033862e..7790d77bc65 100644 --- a/spec/frontend/vue_shared/components/sidebar/copyable_field_spec.js +++ b/spec/frontend/sidebar/components/copy/copyable_field_spec.js @@ -1,7 +1,7 @@ import { GlLoadingIcon, GlSprintf } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyableField from '~/sidebar/components/copy/copyable_field.vue'; describe('SidebarCopyableField', () => { let wrapper; diff --git a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js index 69e35cd1d05..c5161a748a9 100644 --- a/spec/frontend/sidebar/components/reference/sidebar_reference_widget_spec.js +++ b/spec/frontend/sidebar/components/copy/sidebar_reference_widget_spec.js @@ -4,10 +4,10 @@ import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { IssuableType } from '~/issues/constants'; -import SidebarReferenceWidget from '~/sidebar/components/reference/sidebar_reference_widget.vue'; +import SidebarReferenceWidget from '~/sidebar/components/copy/sidebar_reference_widget.vue'; import issueReferenceQuery from '~/sidebar/queries/issue_reference.query.graphql'; import mergeRequestReferenceQuery from '~/sidebar/queries/merge_request_reference.query.graphql'; -import CopyableField from '~/vue_shared/components/sidebar/copyable_field.vue'; +import CopyableField from '~/sidebar/components/copy/copyable_field.vue'; import { issueReferenceResponse } from '../../mock_data'; describe('Sidebar Reference Widget', () => { diff --git a/spec/frontend/sidebar/components/crm_contacts_spec.js b/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js index 6d76fa1f9df..ca43c219d92 100644 --- a/spec/frontend/sidebar/components/crm_contacts_spec.js +++ b/spec/frontend/sidebar/components/crm_contacts/crm_contacts_spec.js @@ -5,13 +5,13 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import CrmContacts from '~/sidebar/components/crm_contacts/crm_contacts.vue'; -import getIssueCrmContactsQuery from '~/sidebar/components/crm_contacts/queries/get_issue_crm_contacts.query.graphql'; -import issueCrmContactsSubscription from '~/sidebar/components/crm_contacts/queries/issue_crm_contacts.subscription.graphql'; +import getIssueCrmContactsQuery from '~/sidebar/queries/get_issue_crm_contacts.query.graphql'; +import issueCrmContactsSubscription from '~/sidebar/queries/issue_crm_contacts.subscription.graphql'; import { getIssueCrmContactsQueryResponse, issueCrmContactsUpdateResponse, issueCrmContactsUpdateNullResponse, -} from './mock_data'; +} from '../mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js index 83764cb6739..1a78ce4ddee 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_status_spec.js @@ -3,11 +3,7 @@ import { nextTick } from 'vue'; import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import EscalationStatus from '~/sidebar/components/incidents/escalation_status.vue'; -import { - STATUS_LABELS, - STATUS_TRIGGERED, - STATUS_ACKNOWLEDGED, -} from '~/sidebar/components/incidents/constants'; +import { STATUS_LABELS, STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; describe('EscalationStatus', () => { let wrapper; diff --git a/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js b/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js index edd65db0325..d9e7f29c10e 100644 --- a/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js +++ b/spec/frontend/sidebar/components/incidents/escalation_utils_spec.js @@ -1,5 +1,5 @@ -import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; -import { getStatusLabel } from '~/sidebar/components/incidents/utils'; +import { STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; +import { getStatusLabel } from '~/sidebar/utils'; describe('EscalationUtils', () => { describe('getStatusLabel', () => { diff --git a/spec/frontend/sidebar/components/incidents/mock_data.js b/spec/frontend/sidebar/components/incidents/mock_data.js index bbb6c61b162..2a5b7798110 100644 --- a/spec/frontend/sidebar/components/incidents/mock_data.js +++ b/spec/frontend/sidebar/components/incidents/mock_data.js @@ -1,4 +1,4 @@ -import { STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; +import { STATUS_TRIGGERED, STATUS_ACKNOWLEDGED } from '~/sidebar/constants'; export const fetchData = { workspace: { diff --git a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js index 88a4913a27f..2dded61c073 100644 --- a/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js +++ b/spec/frontend/sidebar/components/incidents/sidebar_escalation_status_spec.js @@ -1,5 +1,4 @@ -import { createLocalVue } from '@vue/test-utils'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import { fetchData, @@ -12,26 +11,28 @@ import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import SidebarEscalationStatus from '~/sidebar/components/incidents/sidebar_escalation_status.vue'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import { escalationStatusQuery, escalationStatusMutation } from '~/sidebar/constants'; +import { + escalationStatusQuery, + escalationStatusMutation, + STATUS_ACKNOWLEDGED, +} from '~/sidebar/constants'; import waitForPromises from 'helpers/wait_for_promises'; import EscalationStatus from 'ee_else_ce/sidebar/components/incidents/escalation_status.vue'; -import { STATUS_ACKNOWLEDGED } from '~/sidebar/components/incidents/constants'; import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; jest.mock('~/lib/logger'); jest.mock('~/flash'); -const localVue = createLocalVue(); +Vue.use(VueApollo); describe('SidebarEscalationStatus', () => { let wrapper; + let mockApollo; const queryResolverMock = jest.fn(); const mutationResolverMock = jest.fn(); function createMockApolloProvider({ hasFetchError = false, hasMutationError = false } = {}) { - localVue.use(VueApollo); - queryResolverMock.mockResolvedValue({ data: hasFetchError ? fetchError : fetchData }); mutationResolverMock.mockResolvedValue({ data: hasMutationError ? mutationError : mutationData, @@ -45,15 +46,7 @@ describe('SidebarEscalationStatus', () => { return createMockApollo(requestHandlers); } - function createComponent({ mockApollo } = {}) { - let config; - - if (mockApollo) { - config = { apolloProvider: mockApollo }; - } else { - config = { mocks: { $apollo: { queries: { status: { loading: false } } } } }; - } - + function createComponent(apolloProvider) { wrapper = mountExtended(SidebarEscalationStatus, { propsData: { iid: '1', @@ -66,13 +59,15 @@ describe('SidebarEscalationStatus', () => { directives: { GlTooltip: createMockDirective(), }, - localVue, - ...config, + apolloProvider, }); + + // wait for apollo requests + return waitForPromises(); } - afterEach(() => { - wrapper.destroy(); + beforeEach(() => { + mockApollo = createMockApolloProvider(); }); const findSidebarComponent = () => wrapper.findComponent(SidebarEditableItem); @@ -80,36 +75,32 @@ describe('SidebarEscalationStatus', () => { const findEditButton = () => wrapper.findByTestId('edit-button'); const findIcon = () => wrapper.findByTestId('status-icon'); - const clickEditButton = async () => { + const clickEditButton = () => { findEditButton().vm.$emit('click'); - await nextTick(); + return nextTick(); }; - const selectAcknowledgedStatus = async () => { + const selectAcknowledgedStatus = () => { findStatusComponent().vm.$emit('input', STATUS_ACKNOWLEDGED); // wait for apollo requests - await waitForPromises(); + return waitForPromises(); }; describe('sidebar', () => { - it('renders the sidebar component', () => { - createComponent(); + it('renders the sidebar component', async () => { + await createComponent(mockApollo); expect(findSidebarComponent().exists()).toBe(true); }); describe('status icon', () => { - it('is visible', () => { - createComponent(); + it('is visible', async () => { + await createComponent(mockApollo); expect(findIcon().exists()).toBe(true); expect(findIcon().isVisible()).toBe(true); }); it('has correct tooltip', async () => { - const mockApollo = createMockApolloProvider(); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + await createComponent(mockApollo); const tooltip = getBinding(findIcon().element, 'gl-tooltip'); @@ -120,11 +111,7 @@ describe('SidebarEscalationStatus', () => { describe('status dropdown', () => { beforeEach(async () => { - const mockApollo = createMockApolloProvider(); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + await createComponent(mockApollo); }); it('is closed by default', () => { @@ -148,11 +135,7 @@ describe('SidebarEscalationStatus', () => { describe('update Status event', () => { beforeEach(async () => { - const mockApollo = createMockApolloProvider(); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + await createComponent(mockApollo); await clickEditButton(); await selectAcknowledgedStatus(); @@ -184,22 +167,16 @@ describe('SidebarEscalationStatus', () => { describe('mutation errors', () => { it('should error upon fetch', async () => { - const mockApollo = createMockApolloProvider({ hasFetchError: true }); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + mockApollo = createMockApolloProvider({ hasFetchError: true }); + await createComponent(mockApollo); expect(createAlert).toHaveBeenCalled(); expect(logError).toHaveBeenCalled(); }); it('should error upon mutation', async () => { - const mockApollo = createMockApolloProvider({ hasMutationError: true }); - createComponent({ mockApollo }); - - // wait for apollo requests - await waitForPromises(); + mockApollo = createMockApolloProvider({ hasMutationError: true }); + await createComponent(mockApollo); await clickEditButton(); await selectAcknowledgedStatus(); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js index c0e5408e1bd..4f2a89e20db 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_button_spec.js @@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; +import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue'; -import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; import { mockConfig } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js index 799e2c1d08e..59e95edfa20 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view_spec.js @@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_create_view.vue'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_create_view.vue'; -import labelSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import labelSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; import { mockConfig, mockSuggestedColors } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js index cc9b9f393ce..865dc8fe8fb 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view_spec.js @@ -9,13 +9,13 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { UP_KEY_CODE, DOWN_KEY_CODE, ENTER_KEY_CODE, ESC_KEY_CODE } from '~/lib/utils/keycodes'; -import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_labels_view.vue'; -import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_vue/dropdown_contents_labels_view.vue'; +import LabelItem from '~/sidebar/components/labels/labels_select_vue/label_item.vue'; -import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; -import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters'; -import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; -import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; +import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions'; +import * as getters from '~/sidebar/components/labels/labels_select_vue/store/getters'; +import mutations from '~/sidebar/components/labels/labels_select_vue/store/mutations'; +import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state'; import { mockConfig, mockLabels, mockRegularLabel } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js index 9781d9c4de0..e9ffda7c251 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_contents_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_contents_spec.js @@ -2,9 +2,9 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue'; +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; import { mockConfig } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js index 54804f85f81..6c3fda421ff 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_title_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_title_spec.js @@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; +import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; import { mockConfig } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js index c6400320dea..56f25a1c6a4 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon } from '@gitlab/ui'; import { nextTick } from 'vue'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import DropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; +import DropdownValueCollapsedComponent from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue'; import { mockCollapsedLabels as mockLabels, mockRegularLabel } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js index f3c4839002b..a1ccc9d2ab1 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/dropdown_value_spec.js @@ -3,9 +3,9 @@ import { shallowMount } from '@vue/test-utils'; import Vue from 'vue'; import Vuex from 'vuex'; -import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue'; -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; import { mockConfig, mockLabels, mockRegularLabel, mockScopedLabel } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js index bb0f1777de6..e14c0e308ce 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/label_item_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/label_item_spec.js @@ -1,7 +1,7 @@ import { GlLink } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import LabelItem from '~/vue_shared/components/sidebar/labels_select_vue/label_item.vue'; +import LabelItem from '~/sidebar/components/labels/labels_select_vue/label_item.vue'; import { mockRegularLabel } from './mock_data'; const mockLabel = { ...mockRegularLabel, set: true }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js index 30c1a4b7d2f..a3b10c18374 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/labels_select_root_spec.js @@ -3,15 +3,15 @@ import Vue, { nextTick } from 'vue'; import Vuex from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_vue/constants'; -import DropdownButton from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_button.vue'; -import DropdownContents from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_contents.vue'; -import DropdownTitle from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_title.vue'; -import DropdownValue from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value.vue'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; -import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; - -import labelsSelectModule from '~/vue_shared/components/sidebar/labels_select_vue/store'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_vue/constants'; +import DropdownButton from '~/sidebar/components/labels/labels_select_vue/dropdown_button.vue'; +import DropdownContents from '~/sidebar/components/labels/labels_select_vue/dropdown_contents.vue'; +import DropdownTitle from '~/sidebar/components/labels/labels_select_vue/dropdown_title.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_vue/dropdown_value.vue'; +import DropdownValueCollapsed from '~/sidebar/components/labels/labels_select_vue/dropdown_value_collapsed.vue'; +import LabelsSelectRoot from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; + +import labelsSelectModule from '~/sidebar/components/labels/labels_select_vue/store'; import { mockConfig } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js index 884bc4684ba..884bc4684ba 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/mock_data.js diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js index edd044bd754..0e0024aa6c2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/actions_spec.js @@ -3,9 +3,9 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import * as actions from '~/vue_shared/components/sidebar/labels_select_vue/store/actions'; -import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; -import defaultState from '~/vue_shared/components/sidebar/labels_select_vue/store/state'; +import * as actions from '~/sidebar/components/labels/labels_select_vue/store/actions'; +import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types'; +import defaultState from '~/sidebar/components/labels/labels_select_vue/store/state'; jest.mock('~/flash'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js index 6ad46dbe898..e32256831a3 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/getters_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/getters_spec.js @@ -1,4 +1,4 @@ -import * as getters from '~/vue_shared/components/sidebar/labels_select_vue/store/getters'; +import * as getters from '~/sidebar/components/labels/labels_select_vue/store/getters'; describe('LabelsSelect Getters', () => { describe('dropdownButtonText', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js index 2b2508b5e11..cee5d2e77d1 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_vue/store/mutations_spec.js @@ -1,6 +1,6 @@ import { cloneDeep } from 'lodash'; -import * as types from '~/vue_shared/components/sidebar/labels_select_vue/store/mutation_types'; -import mutations from '~/vue_shared/components/sidebar/labels_select_vue/store/mutations'; +import * as types from '~/sidebar/components/labels/labels_select_vue/store/mutation_types'; +import mutations from '~/sidebar/components/labels/labels_select_vue/store/mutations'; describe('LabelsSelect Mutations', () => { describe(`${types.SET_INITIAL_STATE}`, () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js index 237f174e048..79b164b0ea7 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view_spec.js @@ -6,8 +6,8 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { workspaceLabelsQueries } from '~/sidebar/constants'; -import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; -import createLabelMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/create_label.mutation.graphql'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; +import createLabelMutation from '~/sidebar/components/labels/labels_select_widget/graphql/create_label.mutation.graphql'; import { mockRegularLabel, mockSuggestedColors, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js index 5d8ad5ddee5..913badccbe4 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view_spec.js @@ -11,10 +11,10 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; -import projectLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; -import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue'; +import projectLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; import { mockConfig, workspaceLabelsQueryResponse } from './mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js index 00da9b74957..9bbb1413ee9 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_contents_spec.js @@ -1,10 +1,10 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import { DropdownVariant } from '~/vue_shared/components/sidebar/labels_select_widget/constants'; -import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; -import DropdownContentsCreateView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_create_view.vue'; -import DropdownContentsLabelsView from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents_labels_view.vue'; -import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; +import { DropdownVariant } from '~/sidebar/components/labels/labels_select_widget/constants'; +import DropdownContents from '~/sidebar/components/labels/labels_select_widget/dropdown_contents.vue'; +import DropdownContentsCreateView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_create_view.vue'; +import DropdownContentsLabelsView from '~/sidebar/components/labels/labels_select_widget/dropdown_contents_labels_view.vue'; +import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; import { mockLabels } from './mock_data'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js index 0508a059195..9a6e0ca3ccd 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_footer_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_footer_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import DropdownFooter from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_footer.vue'; +import DropdownFooter from '~/sidebar/components/labels/labels_select_widget/dropdown_footer.vue'; describe('DropdownFooter', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js index c4faef8ccdd..d9001dface4 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_header_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_header_spec.js @@ -1,7 +1,7 @@ import { GlSearchBoxByType } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { extendedWrapper } from 'helpers/vue_test_utils_helper'; -import DropdownHeader from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_header.vue'; +import DropdownHeader from '~/sidebar/components/labels/labels_select_widget/dropdown_header.vue'; describe('DropdownHeader', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js index 0c4f4b7d504..585048983c9 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/dropdown_value_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/dropdown_value_spec.js @@ -1,7 +1,7 @@ import { GlLabel } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; import { mockRegularLabel, mockScopedLabel } from './mock_data'; diff --git a/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js new file mode 100644 index 00000000000..4fa65c752f9 --- /dev/null +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/embedded_labels_list_spec.js @@ -0,0 +1,77 @@ +import { GlLabel } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue'; +import { mockRegularLabel, mockScopedLabel } from './mock_data'; + +describe('EmbeddedLabelsList', () => { + let wrapper; + + const findAllLabels = () => wrapper.findAllComponents(GlLabel); + const findLabelByTitle = (title) => + findAllLabels() + .filter((label) => label.props('title') === title) + .at(0); + const findRegularLabel = () => findLabelByTitle(mockRegularLabel.title); + const findScopedLabel = () => findLabelByTitle(mockScopedLabel.title); + + const createComponent = (props = {}, slots = {}) => { + wrapper = shallowMountExtended(EmbeddedLabelsList, { + slots, + propsData: { + selectedLabels: [mockRegularLabel, mockScopedLabel], + allowLabelRemove: true, + labelsFilterBasePath: '/gitlab-org/my-project/issues', + labelsFilterParam: 'label_name', + ...props, + }, + provide: { + allowScopedLabels: true, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when there are no labels', () => { + beforeEach(() => { + createComponent({ + selectedLabels: [], + }); + }); + + it('does not render any labels', () => { + expect(findAllLabels()).toHaveLength(0); + }); + }); + + describe('when there are labels', () => { + beforeEach(() => { + createComponent(); + }); + + it('renders a list of two labels', () => { + expect(findAllLabels()).toHaveLength(2); + }); + + it('passes correct props to the regular label', () => { + expect(findRegularLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', + ); + expect(findRegularLabel().props('scoped')).toBe(false); + }); + + it('passes correct props to the scoped label', () => { + expect(findScopedLabel().props('target')).toBe( + '/gitlab-org/my-project/issues?label_name[]=Foo%3A%3ABar', + ); + expect(findScopedLabel().props('scoped')).toBe(true); + }); + + it('emits `onLabelRemove` event with the correct ID', () => { + findRegularLabel().vm.$emit('close'); + expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[mockRegularLabel.id]]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js index 6e8841411a2..74188a77994 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/label_item_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/label_item_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; -import LabelItem from '~/vue_shared/components/sidebar/labels_select_widget/label_item.vue'; +import LabelItem from '~/sidebar/components/labels/labels_select_widget/label_item.vue'; import { mockRegularLabel } from './mock_data'; const mockLabel = { ...mockRegularLabel, set: true }; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js index 74ddd07d041..2995c268966 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/labels_select_root_spec.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/labels_select_root_spec.js @@ -6,19 +6,22 @@ import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { IssuableType } from '~/issues/constants'; import SidebarEditableItem from '~/sidebar/components/sidebar_editable_item.vue'; -import DropdownContents from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_contents.vue'; -import DropdownValue from '~/vue_shared/components/sidebar/labels_select_widget/dropdown_value.vue'; -import issueLabelsQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/issue_labels.query.graphql'; +import DropdownContents from '~/sidebar/components/labels/labels_select_widget/dropdown_contents.vue'; +import DropdownValue from '~/sidebar/components/labels/labels_select_widget/dropdown_value.vue'; +import EmbeddedLabelsList from '~/sidebar/components/labels/labels_select_widget/embedded_labels_list.vue'; +import issueLabelsQuery from '~/sidebar/components/labels/labels_select_widget/graphql/issue_labels.query.graphql'; import updateIssueLabelsMutation from '~/boards/graphql/issue_set_labels.mutation.graphql'; import updateMergeRequestLabelsMutation from '~/sidebar/queries/update_merge_request_labels.mutation.graphql'; import issuableLabelsSubscription from 'ee_else_ce/sidebar/queries/issuable_labels.subscription.graphql'; -import updateEpicLabelsMutation from '~/vue_shared/components/sidebar/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; -import LabelsSelectRoot from '~/vue_shared/components/sidebar/labels_select_widget/labels_select_root.vue'; +import updateEpicLabelsMutation from '~/sidebar/components/labels/labels_select_widget/graphql/epic_update_labels.mutation.graphql'; +import LabelsSelectRoot from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; import { mockConfig, issuableLabelsQueryResponse, updateLabelsMutationResponse, issuableLabelsSubscriptionResponse, + mockLabels, + mockRegularLabel, } from './mock_data'; jest.mock('~/flash'); @@ -42,6 +45,7 @@ describe('LabelsSelectRoot', () => { const findSidebarEditableItem = () => wrapper.findComponent(SidebarEditableItem); const findDropdownValue = () => wrapper.findComponent(DropdownValue); const findDropdownContents = () => wrapper.findComponent(DropdownContents); + const findEmbeddedLabelsList = () => wrapper.findComponent(EmbeddedLabelsList); const createComponent = ({ config = mockConfig, @@ -151,6 +155,52 @@ describe('LabelsSelectRoot', () => { }); }); + describe('if dropdown variant is `embedded`', () => { + it('shows the embedded labels list', () => { + createComponent({ + config: { ...mockConfig, iid: '', variant: 'embedded', showEmbeddedLabelsList: true }, + }); + + expect(findEmbeddedLabelsList().props()).toMatchObject({ + disabled: false, + selectedLabels: [], + allowLabelRemove: false, + labelsFilterBasePath: mockConfig.labelsFilterBasePath, + labelsFilterParam: mockConfig.labelsFilterParam, + }); + }); + + it('passes the selected labels if provided', () => { + createComponent({ + config: { + ...mockConfig, + iid: '', + variant: 'embedded', + showEmbeddedLabelsList: true, + selectedLabels: mockLabels, + }, + }); + + expect(findEmbeddedLabelsList().props('selectedLabels')).toStrictEqual(mockLabels); + expect(findDropdownContents().props('selectedLabels')).toStrictEqual(mockLabels); + }); + + it('emits the `onLabelRemove` when the embedded list triggers a removal', () => { + createComponent({ + config: { + ...mockConfig, + iid: '', + variant: 'embedded', + showEmbeddedLabelsList: true, + selectedLabels: [mockRegularLabel], + }, + }); + + findEmbeddedLabelsList().vm.$emit('onLabelRemove', [mockRegularLabel.id]); + expect(wrapper.emitted('onLabelRemove')).toStrictEqual([[[mockRegularLabel.id]]]); + }); + }); + it('emits `updateSelectedLabels` event on dropdown contents `setLabels` event if iid is not set', async () => { const label = { id: 'gid://gitlab/ProjectLabel/1' }; createComponent({ config: { ...mockConfig, iid: undefined } }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js index 48530a0261f..48530a0261f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_widget/mock_data.js +++ b/spec/frontend/sidebar/components/labels/labels_select_widget/mock_data.js diff --git a/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap b/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap index 18d4df297df..18d4df297df 100644 --- a/spec/frontend/sidebar/lock/__snapshots__/edit_form_spec.js.snap +++ b/spec/frontend/sidebar/components/lock/__snapshots__/edit_form_spec.js.snap diff --git a/spec/frontend/sidebar/lock/constants.js b/spec/frontend/sidebar/components/lock/constants.js index b9f08e9286d..b9f08e9286d 100644 --- a/spec/frontend/sidebar/lock/constants.js +++ b/spec/frontend/sidebar/components/lock/constants.js diff --git a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js b/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js index 2abb0c24d7d..2abb0c24d7d 100644 --- a/spec/frontend/sidebar/lock/edit_form_buttons_spec.js +++ b/spec/frontend/sidebar/components/lock/edit_form_buttons_spec.js diff --git a/spec/frontend/sidebar/lock/edit_form_spec.js b/spec/frontend/sidebar/components/lock/edit_form_spec.js index 4ae9025ee39..4ae9025ee39 100644 --- a/spec/frontend/sidebar/lock/edit_form_spec.js +++ b/spec/frontend/sidebar/components/lock/edit_form_spec.js diff --git a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js index 8f825847cfc..8f825847cfc 100644 --- a/spec/frontend/sidebar/lock/issuable_lock_form_spec.js +++ b/spec/frontend/sidebar/components/lock/issuable_lock_form_spec.js diff --git a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js index d531147c0e6..72279f44e80 100644 --- a/spec/frontend/vue_shared/components/sidebar/issuable_move_dropdown_spec.js +++ b/spec/frontend/sidebar/components/move/issuable_move_dropdown_spec.js @@ -12,7 +12,7 @@ import MockAdapter from 'axios-mock-adapter'; import { nextTick } from 'vue'; import axios from '~/lib/utils/axios_utils'; -import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; +import IssuableMoveDropdown from '~/sidebar/components/move/issuable_move_dropdown.vue'; const mockProjects = [ { diff --git a/spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js b/spec/frontend/sidebar/components/move/move_issues_button_spec.js index c432d722637..999340da27c 100644 --- a/spec/frontend/issuable/bulk_update_sidebar/components/move_issues_button_spec.js +++ b/spec/frontend/sidebar/components/move/move_issues_button_spec.js @@ -6,12 +6,12 @@ import { GlAlert } from '@gitlab/ui'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; -import createFlash from '~/flash'; +import { createAlert } from '~/flash'; import { logError } from '~/lib/logger'; -import IssuableMoveDropdown from '~/vue_shared/components/sidebar/issuable_move_dropdown.vue'; -import MoveIssuesButton from '~/issuable/bulk_update_sidebar/components/move_issues_button.vue'; +import IssuableMoveDropdown from '~/sidebar/components/move/issuable_move_dropdown.vue'; import issuableEventHub from '~/issues/list/eventhub'; -import moveIssueMutation from '~/issuable/bulk_update_sidebar/components/graphql/mutations/move_issue.mutation.graphql'; +import MoveIssuesButton from '~/sidebar/components/move/move_issues_button.vue'; +import moveIssueMutation from '~/sidebar/queries/move_issue.mutation.graphql'; import getIssuesQuery from 'ee_else_ce/issues/list/queries/get_issues.query.graphql'; import getIssuesCountsQuery from 'ee_else_ce/issues/list/queries/get_issues_counts.query.graphql'; import { getIssuesCountsQueryResponse, getIssuesQueryResponse } from 'jest/issues/list/mock_data'; @@ -389,7 +389,7 @@ describe('MoveIssuesButton', () => { await waitForPromises(); expect(logError).not.toHaveBeenCalled(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('does not create flashes or logs errors when only tasks are selected', async () => { @@ -399,7 +399,7 @@ describe('MoveIssuesButton', () => { await waitForPromises(); expect(logError).not.toHaveBeenCalled(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('does not create flashes or logs errors when only test cases are selected', async () => { @@ -409,7 +409,7 @@ describe('MoveIssuesButton', () => { await waitForPromises(); expect(logError).not.toHaveBeenCalled(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('does not create flashes or logs errors when only tasks and test cases are selected', async () => { @@ -419,7 +419,7 @@ describe('MoveIssuesButton', () => { await waitForPromises(); expect(logError).not.toHaveBeenCalled(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('does not create flashes or logs errors when issues are moved without errors', async () => { @@ -432,7 +432,7 @@ describe('MoveIssuesButton', () => { await waitForPromises(); expect(logError).not.toHaveBeenCalled(); - expect(createFlash).not.toHaveBeenCalled(); + expect(createAlert).not.toHaveBeenCalled(); }); it('creates a flash and logs errors when a mutation returns errors', async () => { @@ -456,8 +456,8 @@ describe('MoveIssuesButton', () => { ); // Only one flash is created even if multiple errors are reported - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'There was an error while moving the issues.', }); }); @@ -469,8 +469,8 @@ describe('MoveIssuesButton', () => { await waitForPromises(); expect(logError).not.toHaveBeenCalled(); - expect(createFlash).toHaveBeenCalledTimes(1); - expect(createFlash).toHaveBeenCalledWith({ + expect(createAlert).toHaveBeenCalledTimes(1); + expect(createAlert).toHaveBeenCalledWith({ message: 'There was an error while moving the issues.', }); }); diff --git a/spec/frontend/sidebar/participants_spec.js b/spec/frontend/sidebar/components/participants/participants_spec.js index f7a626a189c..f7a626a189c 100644 --- a/spec/frontend/sidebar/participants_spec.js +++ b/spec/frontend/sidebar/components/participants/participants_spec.js diff --git a/spec/frontend/sidebar/reviewer_title_spec.js b/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js index 68ecd62e4c6..68ecd62e4c6 100644 --- a/spec/frontend/sidebar/reviewer_title_spec.js +++ b/spec/frontend/sidebar/components/reviewers/reviewer_title_spec.js diff --git a/spec/frontend/sidebar/reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/reviewers_spec.js index 229f7ffbe04..229f7ffbe04 100644 --- a/spec/frontend/sidebar/reviewers_spec.js +++ b/spec/frontend/sidebar/components/reviewers/reviewers_spec.js diff --git a/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js new file mode 100644 index 00000000000..57ae146a27a --- /dev/null +++ b/spec/frontend/sidebar/components/reviewers/sidebar_reviewers_spec.js @@ -0,0 +1,77 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import axios from 'axios'; +import AxiosMockAdapter from 'axios-mock-adapter'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import SidebarReviewers from '~/sidebar/components/reviewers/sidebar_reviewers.vue'; +import SidebarService from '~/sidebar/services/sidebar_service'; +import SidebarMediator from '~/sidebar/sidebar_mediator'; +import SidebarStore from '~/sidebar/stores/sidebar_store'; +import Mock from '../../mock_data'; + +Vue.use(VueApollo); + +describe('sidebar reviewers', () => { + const apolloMock = createMockApollo(); + let wrapper; + let mediator; + let axiosMock; + + const createComponent = (props) => { + wrapper = shallowMount(SidebarReviewers, { + apolloProvider: apolloMock, + propsData: { + issuableIid: '1', + issuableId: 1, + mediator, + field: '', + projectPath: 'projectPath', + changing: false, + ...props, + }, + // Attaching to document is required because this component emits something from the parent element :/ + attachTo: document.body, + }); + }; + + beforeEach(() => { + axiosMock = new AxiosMockAdapter(axios); + mediator = new SidebarMediator(Mock.mediator); + + jest.spyOn(mediator, 'saveReviewers'); + jest.spyOn(mediator, 'addSelfReview'); + }); + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + + SidebarService.singleton = null; + SidebarStore.singleton = null; + SidebarMediator.singleton = null; + axiosMock.restore(); + }); + + it('calls the mediator when it saves the reviewers', () => { + createComponent(); + + expect(mediator.saveReviewers).not.toHaveBeenCalled(); + + wrapper.vm.saveReviewers(); + + expect(mediator.saveReviewers).toHaveBeenCalled(); + }); + + it('calls the mediator when "reviewBySelf" method is called', () => { + createComponent(); + + expect(mediator.addSelfReview).not.toHaveBeenCalled(); + expect(mediator.store.reviewers.length).toBe(0); + + wrapper.vm.reviewBySelf(); + + expect(mediator.addSelfReview).toHaveBeenCalled(); + expect(mediator.store.reviewers.length).toBe(1); + }); +}); diff --git a/spec/frontend/sidebar/components/severity/severity_spec.js b/spec/frontend/sidebar/components/severity/severity_spec.js index 2146155791e..99d33e840d5 100644 --- a/spec/frontend/sidebar/components/severity/severity_spec.js +++ b/spec/frontend/sidebar/components/severity/severity_spec.js @@ -1,6 +1,6 @@ import { GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { INCIDENT_SEVERITY } from '~/sidebar/components/severity/constants'; +import { INCIDENT_SEVERITY } from '~/sidebar/constants'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; describe('SeverityToken', () => { diff --git a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js index bdea33371d8..8f936240b7a 100644 --- a/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js +++ b/spec/frontend/sidebar/components/severity/sidebar_severity_spec.js @@ -3,8 +3,8 @@ import { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; -import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/components/severity/constants'; -import updateIssuableSeverity from '~/sidebar/components/severity/graphql/mutations/update_issuable_severity.mutation.graphql'; +import { INCIDENT_SEVERITY, ISSUABLE_TYPES } from '~/sidebar/constants'; +import updateIssuableSeverity from '~/sidebar/queries/update_issuable_severity.mutation.graphql'; import SeverityToken from '~/sidebar/components/severity/severity.vue'; import SidebarSeverity from '~/sidebar/components/severity/sidebar_severity.vue'; diff --git a/spec/frontend/issuable/bulk_update_sidebar/components/status_dropdown_spec.js b/spec/frontend/sidebar/components/status/status_dropdown_spec.js index 2f281cb88f9..5a75299c3a4 100644 --- a/spec/frontend/issuable/bulk_update_sidebar/components/status_dropdown_spec.js +++ b/spec/frontend/sidebar/components/status/status_dropdown_spec.js @@ -1,7 +1,7 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import StatusDropdown from '~/issuable/bulk_update_sidebar/components/status_dropdown.vue'; -import { statusDropdownOptions } from '~/issuable/bulk_update_sidebar/constants'; +import StatusDropdown from '~/sidebar/components/status/status_dropdown.vue'; +import { statusDropdownOptions } from '~/sidebar/constants'; describe('SubscriptionsDropdown component', () => { let wrapper; diff --git a/spec/frontend/issuable/bulk_update_sidebar/components/subscriptions_dropdown_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js index 56ef7a1ed39..3fb8214606c 100644 --- a/spec/frontend/issuable/bulk_update_sidebar/components/subscriptions_dropdown_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_dropdown_spec.js @@ -1,8 +1,8 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import SubscriptionsDropdown from '~/issuable/bulk_update_sidebar/components/subscriptions_dropdown.vue'; -import { subscriptionsDropdownOptions } from '~/issuable/bulk_update_sidebar/constants'; +import SubscriptionsDropdown from '~/sidebar/components/subscriptions/subscriptions_dropdown.vue'; +import { subscriptionsDropdownOptions } from '~/sidebar/constants'; describe('SubscriptionsDropdown component', () => { let wrapper; diff --git a/spec/frontend/sidebar/subscriptions_spec.js b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js index 1a1aa370eef..1a1aa370eef 100644 --- a/spec/frontend/sidebar/subscriptions_spec.js +++ b/spec/frontend/sidebar/components/subscriptions/subscriptions_spec.js diff --git a/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js new file mode 100644 index 00000000000..cb3bb7a4538 --- /dev/null +++ b/spec/frontend/sidebar/components/time_tracking/create_timelog_form_spec.js @@ -0,0 +1,219 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; +import { GlAlert, GlModal } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { convertToGraphQLId } from '~/graphql_shared/utils'; +import CreateTimelogForm from '~/sidebar/components/time_tracking/create_timelog_form.vue'; +import createTimelogMutation from '~/sidebar/queries/create_timelog.mutation.graphql'; +import { TYPE_ISSUE, TYPE_MERGE_REQUEST } from '~/graphql_shared/constants'; + +const mockMutationErrorMessage = 'Example error message'; + +const resolvedMutationWithoutErrorsMock = jest.fn().mockResolvedValue({ + data: { + timelogCreate: { + errors: [], + timelog: { + id: 'gid://gitlab/Timelog/1', + issue: {}, + mergeRequest: {}, + }, + }, + }, +}); + +const resolvedMutationWithErrorsMock = jest.fn().mockResolvedValue({ + data: { + timelogCreate: { + errors: [{ message: mockMutationErrorMessage }], + timelog: null, + }, + }, +}); + +const rejectedMutationMock = jest.fn().mockRejectedValue(); +const modalCloseMock = jest.fn(); + +describe('Create Timelog Form', () => { + Vue.use(VueApollo); + + let wrapper; + let fakeApollo; + + const findForm = () => wrapper.find('form'); + const findModal = () => wrapper.findComponent(GlModal); + const findAlert = () => wrapper.findComponent(GlAlert); + const findDocsLink = () => wrapper.findByTestId('timetracking-docs-link'); + const findSaveButton = () => findModal().props('actionPrimary'); + const findSaveButtonLoadingState = () => findSaveButton().attributes[0].loading; + const findSaveButtonDisabledState = () => findSaveButton().attributes[0].disabled; + + const submitForm = () => findForm().trigger('submit'); + + const mountComponent = ( + { props, data, providedProps } = {}, + mutationResolverMock = rejectedMutationMock, + ) => { + fakeApollo = createMockApollo([[createTimelogMutation, mutationResolverMock]]); + + wrapper = shallowMountExtended(CreateTimelogForm, { + data() { + return { + ...data, + }; + }, + provide: { + issuableType: 'issue', + ...providedProps, + }, + propsData: { + issuableId: '1', + ...props, + }, + apolloProvider: fakeApollo, + }); + + wrapper.vm.$refs.modal.close = modalCloseMock; + }; + + afterEach(() => { + fakeApollo = null; + }); + + describe('save button', () => { + it('is disabled and not loading by default', () => { + mountComponent(); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading when time spent is not empty', () => { + mountComponent({ data: { timeSpent: '2d' } }); + + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is disabled and loading when the the form is submitted', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await nextTick(); + + expect(findSaveButtonLoadingState()).toBe(true); + expect(findSaveButtonDisabledState()).toBe(true); + }); + + it('is enabled and not loading the when form is submitted but the mutation has errors', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalled(); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + + it('is enabled and not loading the when form is submitted but the mutation returns errors', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + + submitForm(); + + await waitForPromises(); + + expect(resolvedMutationWithErrorsMock).toHaveBeenCalled(); + expect(findSaveButtonLoadingState()).toBe(false); + expect(findSaveButtonDisabledState()).toBe(false); + }); + }); + + describe('form', () => { + it('does not call any mutation when the the form is incomplete', async () => { + mountComponent(); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).not.toHaveBeenCalled(); + }); + + it('closes the modal after a successful mutation', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithoutErrorsMock); + + submitForm(); + + await waitForPromises(); + await nextTick(); + + expect(modalCloseMock).toHaveBeenCalled(); + }); + + it.each` + issuableType | typeConstant + ${'issue'} | ${TYPE_ISSUE} + ${'merge_request'} | ${TYPE_MERGE_REQUEST} + `( + 'calls the mutation with all the fields when the the form is submitted and issuable type is $issuableType', + async ({ issuableType, typeConstant }) => { + const timeSpent = '2d'; + const spentAt = '2022-11-20T21:53:00+0000'; + const summary = 'Example'; + + mountComponent({ data: { timeSpent, spentAt, summary }, providedProps: { issuableType } }); + + submitForm(); + + await waitForPromises(); + + expect(rejectedMutationMock).toHaveBeenCalledWith({ + input: { timeSpent, spentAt, summary, issuableId: convertToGraphQLId(typeConstant, '1') }, + }); + }, + ); + }); + + describe('alert', () => { + it('is hidden by default', () => { + mountComponent(); + + expect(findAlert().exists()).toBe(false); + }); + + it('shows an error if the submission fails with a handled error', async () => { + mountComponent({ data: { timeSpent: '2d' } }, resolvedMutationWithErrorsMock); + + submitForm(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe(mockMutationErrorMessage); + }); + + it('shows an error if the submission fails with an unhandled error', async () => { + mountComponent({ data: { timeSpent: '2d' } }); + + submitForm(); + + await waitForPromises(); + + expect(findAlert().exists()).toBe(true); + expect(findAlert().text()).toBe('An error occurred while saving the time entry.'); + }); + }); + + describe('docs link message', () => { + it('is present', () => { + mountComponent(); + + expect(findDocsLink().exists()).toBe(true); + }); + }); +}); diff --git a/spec/frontend/sidebar/components/time_tracking/report_spec.js b/spec/frontend/sidebar/components/time_tracking/report_spec.js index af72122052f..0259aee48f0 100644 --- a/spec/frontend/sidebar/components/time_tracking/report_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/report_spec.js @@ -8,9 +8,9 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import Report from '~/sidebar/components/time_tracking/report.vue'; -import getIssueTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_issue_timelogs.query.graphql'; -import getMrTimelogsQuery from '~/vue_shared/components/sidebar/queries/get_mr_timelogs.query.graphql'; -import deleteTimelogMutation from '~/sidebar/components/time_tracking/graphql/mutations/delete_timelog.mutation.graphql'; +import getIssueTimelogsQuery from '~/sidebar/queries/get_issue_timelogs.query.graphql'; +import getMrTimelogsQuery from '~/sidebar/queries/get_mr_timelogs.query.graphql'; +import deleteTimelogMutation from '~/sidebar/queries/delete_timelog.mutation.graphql'; import { getIssueTimelogsQueryResponse, getMrTimelogsQueryResponse, diff --git a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js index 835e700e63c..45d8b5e4647 100644 --- a/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js +++ b/spec/frontend/sidebar/components/time_tracking/time_tracker_spec.js @@ -268,47 +268,32 @@ describe('Issuable Time Tracker', () => { }); }); - describe('Help pane', () => { - const findHelpButton = () => findByTestId('helpButton'); - const findCloseHelpButton = () => findByTestId('closeHelpButton'); - - beforeEach(async () => { - wrapper = mountComponent({ - props: { - initialTimeTracking: { - timeEstimate: 0, - totalTimeSpent: 0, - humanTimeEstimate: '', - humanTotalTimeSpent: '', + describe('Add button', () => { + const findAddButton = () => findByTestId('add-time-entry-button'); + + it.each` + visibility | canAddTimeEntries + ${'not visible'} | ${false} + ${'visible'} | ${true} + `( + 'is $visibility when canAddTimeEntries is $canAddTimeEntries', + async ({ canAddTimeEntries }) => { + wrapper = mountComponent({ + props: { + initialTimeTracking: { + timeEstimate: 0, + totalTimeSpent: 0, + humanTimeEstimate: '', + humanTotalTimeSpent: '', + }, + canAddTimeEntries, }, - }, - }); - await nextTick(); - }); - - it('should not show the "Help" pane by default', () => { - expect(findByTestId('helpPane').exists()).toBe(false); - }); - - it('should show the "Help" pane when help button is clicked', async () => { - findHelpButton().trigger('click'); - - await nextTick(); - - expect(findByTestId('helpPane').exists()).toBe(true); - }); - - it('should not show the "Help" pane when help button is clicked and then closed', async () => { - findHelpButton().trigger('click'); - await nextTick(); - - expect(findByTestId('helpPane').exists()).toBe(true); - - findCloseHelpButton().trigger('click'); - await nextTick(); + }); + await nextTick(); - expect(findByTestId('helpPane').exists()).toBe(false); - }); + expect(findAddButton().exists()).toBe(canAddTimeEntries); + }, + ); }); }); diff --git a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap index 846f45345e7..846f45345e7 100644 --- a/spec/frontend/sidebar/__snapshots__/todo_spec.js.snap +++ b/spec/frontend/sidebar/components/todo_toggle/__snapshots__/todo_spec.js.snap diff --git a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js index f73491ca95f..5bfe3b59eb3 100644 --- a/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/sidebar_todo_widget_spec.js @@ -7,7 +7,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import SidebarTodoWidget from '~/sidebar/components/todo_toggle/sidebar_todo_widget.vue'; import epicTodoQuery from '~/sidebar/queries/epic_todo.query.graphql'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; import { todosResponse, noTodosResponse } from '../../mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/vue_shared/components/sidebar/todo_button_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js index 01958a144ed..fb07029a249 100644 --- a/spec/frontend/vue_shared/components/sidebar/todo_button_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/todo_button_spec.js @@ -1,6 +1,6 @@ import { GlButton } from '@gitlab/ui'; import { shallowMount, mount } from '@vue/test-utils'; -import TodoButton from '~/vue_shared/components/sidebar/todo_toggle/todo_button.vue'; +import TodoButton from '~/sidebar/components/todo_toggle/todo_button.vue'; describe('Todo Button', () => { let wrapper; diff --git a/spec/frontend/sidebar/todo_spec.js b/spec/frontend/sidebar/components/todo_toggle/todo_spec.js index 8e6597bf80f..8e6597bf80f 100644 --- a/spec/frontend/sidebar/todo_spec.js +++ b/spec/frontend/sidebar/components/todo_toggle/todo_spec.js diff --git a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js index 267a467059d..cf9b2828dde 100644 --- a/spec/frontend/vue_shared/components/sidebar/toggle_sidebar_spec.js +++ b/spec/frontend/sidebar/components/toggle/toggle_sidebar_spec.js @@ -2,7 +2,7 @@ import { GlButton } from '@gitlab/ui'; import { mount, shallowMount } from '@vue/test-utils'; import { nextTick } from 'vue'; -import ToggleSidebar from '~/vue_shared/components/sidebar/toggle_sidebar.vue'; +import ToggleSidebar from '~/sidebar/components/toggle/toggle_sidebar.vue'; describe('ToggleSidebar', () => { let wrapper; diff --git a/spec/frontend/sidebar/sidebar_move_issue_spec.js b/spec/frontend/sidebar/lib/sidebar_move_issue_spec.js index 195cc6ddeeb..6e365df329b 100644 --- a/spec/frontend/sidebar/sidebar_move_issue_spec.js +++ b/spec/frontend/sidebar/lib/sidebar_move_issue_spec.js @@ -8,7 +8,7 @@ import SidebarService from '~/sidebar/services/sidebar_service'; import SidebarMediator from '~/sidebar/sidebar_mediator'; import SidebarStore from '~/sidebar/stores/sidebar_store'; import { GitLabDropdown } from '~/deprecated_jquery_dropdown/gl_dropdown'; -import Mock from './mock_data'; +import Mock from '../mock_data'; jest.mock('~/flash'); diff --git a/spec/frontend/sidebar/sidebar_mediator_spec.js b/spec/frontend/sidebar/sidebar_mediator_spec.js index bb5e7f7ff16..cdb9ced70b8 100644 --- a/spec/frontend/sidebar/sidebar_mediator_spec.js +++ b/spec/frontend/sidebar/sidebar_mediator_spec.js @@ -24,7 +24,8 @@ describe('Sidebar mediator', () => { SidebarService.singleton = null; SidebarStore.singleton = null; SidebarMediator.singleton = null; - mock.restore(); + + jest.clearAllMocks(); }); it('assigns yourself', () => { @@ -42,6 +43,52 @@ describe('Sidebar mediator', () => { }); }); + it('assigns yourself as a reviewer', () => { + mediator.addSelfReview(); + + expect(mediator.store.currentUser).toEqual(mediatorMockData.currentUser); + expect(mediator.store.reviewers[0]).toEqual(mediatorMockData.currentUser); + }); + + describe('saves reviewers', () => { + const mockUpdateResponseData = { + reviewers: [1, 2], + assignees: [3, 4], + }; + const field = 'merge_request[reviewers_ids]'; + const reviewers = [ + { id: 1, suggested: true }, + { id: 2, suggested: false }, + ]; + + let serviceSpy; + + beforeEach(() => { + mediator.store.reviewers = reviewers; + serviceSpy = jest + .spyOn(mediator.service, 'update') + .mockReturnValue(Promise.resolve({ data: mockUpdateResponseData })); + }); + + it('sends correct data to service', () => { + const data = { + reviewer_ids: [1, 2], + suggested_reviewer_ids: [1], + }; + + mediator.saveReviewers(field); + + expect(serviceSpy).toHaveBeenCalledWith(field, data); + }); + + it('saves reviewers', () => { + return mediator.saveReviewers(field).then(() => { + expect(mediator.store.assignees).toEqual(mockUpdateResponseData.assignees); + expect(mediator.store.reviewers).toEqual(mockUpdateResponseData.reviewers); + }); + }); + }); + it('fetches the data', async () => { const mockData = Mock.responseMap.GET[mediatorMockData.endpoint]; mock.onGet(mediatorMockData.endpoint).reply(200, mockData); @@ -49,7 +96,6 @@ describe('Sidebar mediator', () => { await mediator.fetch(); expect(spy).toHaveBeenCalledWith(mockData); - spy.mockRestore(); }); it('processes fetched data', () => { @@ -70,8 +116,6 @@ describe('Sidebar mediator', () => { mediator.setMoveToProjectId(projectId); expect(spy).toHaveBeenCalledWith(projectId); - - spy.mockRestore(); }); it('fetches autocomplete projects', () => { @@ -87,9 +131,6 @@ describe('Sidebar mediator', () => { return mediator.fetchAutocompleteProjects(searchTerm).then(() => { expect(getterSpy).toHaveBeenCalledWith(searchTerm); expect(setterSpy).toHaveBeenCalled(); - - getterSpy.mockRestore(); - setterSpy.mockRestore(); }); }); @@ -106,9 +147,6 @@ describe('Sidebar mediator', () => { return mediator.moveIssue().then(() => { expect(moveIssueSpy).toHaveBeenCalledWith(moveToProjectId); expect(urlSpy).toHaveBeenCalledWith(mockData.web_url); - - moveIssueSpy.mockRestore(); - urlSpy.mockRestore(); }); }); }); diff --git a/spec/frontend/sidebar/sidebar_store_spec.js b/spec/frontend/sidebar/stores/sidebar_store_spec.js index 3930dabfcfa..3f4b80409c2 100644 --- a/spec/frontend/sidebar/sidebar_store_spec.js +++ b/spec/frontend/sidebar/stores/sidebar_store_spec.js @@ -1,6 +1,6 @@ import UsersMockHelper from 'helpers/user_mock_data_helper'; import SidebarStore from '~/sidebar/stores/sidebar_store'; -import Mock from './mock_data'; +import Mock from '../mock_data'; const ASSIGNEE = { id: 2, diff --git a/spec/frontend/terms/components/app_spec.js b/spec/frontend/terms/components/app_spec.js index f1dbc004da8..ce1c126f868 100644 --- a/spec/frontend/terms/components/app_spec.js +++ b/spec/frontend/terms/components/app_spec.js @@ -1,4 +1,3 @@ -import $ from 'jquery'; import { merge } from 'lodash'; import { GlIntersectionObserver } from '@gitlab/ui'; import { nextTick } from 'vue'; @@ -7,13 +6,14 @@ import { mountExtended } from 'helpers/vue_test_utils_helper'; import { FLASH_TYPES, FLASH_CLOSED_EVENT } from '~/flash'; import { isLoggedIn } from '~/lib/utils/common_utils'; import TermsApp from '~/terms/components/app.vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; jest.mock('~/lib/utils/csrf', () => ({ token: 'mock-csrf-token' })); jest.mock('~/lib/utils/common_utils'); +jest.mock('~/behaviors/markdown/render_gfm'); describe('TermsApp', () => { let wrapper; - let renderGFMSpy; const defaultProvide = { terms: 'foo bar', @@ -35,7 +35,6 @@ describe('TermsApp', () => { }; beforeEach(() => { - renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); isLoggedIn.mockReturnValue(true); }); @@ -65,7 +64,7 @@ describe('TermsApp', () => { createComponent(); expect(wrapper.findByText(defaultProvide.terms).exists()).toBe(true); - expect(renderGFMSpy).toHaveBeenCalled(); + expect(renderGFM).toHaveBeenCalled(); }); describe('accept button', () => { diff --git a/spec/frontend/terraform/components/init_command_modal_spec.js b/spec/frontend/terraform/components/init_command_modal_spec.js index dbdff899bac..911bb8878da 100644 --- a/spec/frontend/terraform/components/init_command_modal_spec.js +++ b/spec/frontend/terraform/components/init_command_modal_spec.js @@ -7,12 +7,13 @@ const accessTokensPath = '/path/to/access-tokens-page'; const terraformApiUrl = 'https://gitlab.com/api/v4/projects/1'; const username = 'username'; const modalId = 'fake-modal-id'; -const stateName = 'production'; +const stateName = 'aws/eu-central-1'; +const stateNameEncoded = encodeURIComponent(stateName); const modalInfoCopyStr = `export GITLAB_ACCESS_TOKEN=<YOUR-ACCESS-TOKEN> terraform init \\ - -backend-config="address=${terraformApiUrl}/${stateName}" \\ - -backend-config="lock_address=${terraformApiUrl}/${stateName}/lock" \\ - -backend-config="unlock_address=${terraformApiUrl}/${stateName}/lock" \\ + -backend-config="address=${terraformApiUrl}/${stateNameEncoded}" \\ + -backend-config="lock_address=${terraformApiUrl}/${stateNameEncoded}/lock" \\ + -backend-config="unlock_address=${terraformApiUrl}/${stateNameEncoded}/lock" \\ -backend-config="username=${username}" \\ -backend-config="password=$GITLAB_ACCESS_TOKEN" \\ -backend-config="lock_method=POST" \\ @@ -61,9 +62,15 @@ describe('InitCommandModal', () => { expect(findLink().attributes('href')).toBe(accessTokensPath); }); - it('renders the init command with the username and state name prepopulated', () => { - expect(findInitCommand().text()).toContain(username); - expect(findInitCommand().text()).toContain(stateName); + describe('init command', () => { + it('includes correct address', () => { + expect(findInitCommand().text()).toContain( + `-backend-config="address=${terraformApiUrl}/${stateNameEncoded}"`, + ); + }); + it('includes correct username', () => { + expect(findInitCommand().text()).toContain(`-backend-config="username=${username}"`); + }); }); it('renders the copyToClipboard button', () => { diff --git a/spec/frontend/token_access/mock_data.js b/spec/frontend/token_access/mock_data.js index 2eed1e30d0d..0c8ba266201 100644 --- a/spec/frontend/token_access/mock_data.js +++ b/spec/frontend/token_access/mock_data.js @@ -68,6 +68,19 @@ export const removeProjectSuccess = { }, }; +export const updateScopeSuccess = { + data: { + ciCdSettingsUpdate: { + ciCdSettings: { + jobTokenScopeEnabled: false, + __typename: 'ProjectCiCdSetting', + }, + errors: [], + __typename: 'CiCdSettingsUpdatePayload', + }, + }, +}; + export const mockProjects = [ { id: '1', diff --git a/spec/frontend/token_access/token_access_spec.js b/spec/frontend/token_access/token_access_spec.js index ea1d9db515a..6fe94e28548 100644 --- a/spec/frontend/token_access/token_access_spec.js +++ b/spec/frontend/token_access/token_access_spec.js @@ -8,6 +8,7 @@ import { createAlert } from '~/flash'; import TokenAccess from '~/token_access/components/token_access.vue'; import addProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/add_project_ci_job_token_scope.mutation.graphql'; import removeProjectCIJobTokenScopeMutation from '~/token_access/graphql/mutations/remove_project_ci_job_token_scope.mutation.graphql'; +import updateCIJobTokenScopeMutation from '~/token_access/graphql/mutations/update_ci_job_token_scope.mutation.graphql'; import getCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_ci_job_token_scope.query.graphql'; import getProjectsWithCIJobTokenScopeQuery from '~/token_access/graphql/queries/get_projects_with_ci_job_token_scope.query.graphql'; import { @@ -16,6 +17,7 @@ import { projectsWithScope, addProjectSuccess, removeProjectSuccess, + updateScopeSuccess, } from './mock_data'; const projectPath = 'root/my-repo'; @@ -31,11 +33,11 @@ describe('TokenAccess component', () => { const enabledJobTokenScopeHandler = jest.fn().mockResolvedValue(enabledJobTokenScope); const disabledJobTokenScopeHandler = jest.fn().mockResolvedValue(disabledJobTokenScope); - const getProjectsWithScope = jest.fn().mockResolvedValue(projectsWithScope); + const getProjectsWithScopeHandler = jest.fn().mockResolvedValue(projectsWithScope); const addProjectSuccessHandler = jest.fn().mockResolvedValue(addProjectSuccess); - const addProjectFailureHandler = jest.fn().mockRejectedValue(error); const removeProjectSuccessHandler = jest.fn().mockResolvedValue(removeProjectSuccess); - const removeProjectFailureHandler = jest.fn().mockRejectedValue(error); + const updateScopeSuccessHandler = jest.fn().mockResolvedValue(updateScopeSuccess); + const failureHandler = jest.fn().mockRejectedValue(error); const findToggle = () => wrapper.findComponent(GlToggle); const findLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); @@ -69,7 +71,7 @@ describe('TokenAccess component', () => { it('shows loading state while waiting on query to resolve', async () => { createComponent([ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], ]); expect(findLoadingIcon().exists()).toBe(true); @@ -80,11 +82,53 @@ describe('TokenAccess component', () => { }); }); + describe('fetching projects and scope', () => { + it('fetches projects and scope correctly', () => { + const expectedVariables = { + fullPath: 'root/my-repo', + }; + + createComponent([ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], + ]); + + expect(enabledJobTokenScopeHandler).toHaveBeenCalledWith(expectedVariables); + expect(getProjectsWithScopeHandler).toHaveBeenCalledWith(expectedVariables); + }); + + it('handles fetch projects error correctly', async () => { + createComponent([ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [getProjectsWithCIJobTokenScopeQuery, failureHandler], + ]); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching the projects', + }); + }); + + it('handles fetch scope error correctly', async () => { + createComponent([ + [getCIJobTokenScopeQuery, failureHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], + ]); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + message: 'There was a problem fetching the job token scope value', + }); + }); + }); + describe('toggle', () => { it('the toggle is on and the alert is hidden', async () => { createComponent([ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], ]); await waitForPromises(); @@ -96,7 +140,7 @@ describe('TokenAccess component', () => { it('the toggle is off and the alert is visible', async () => { createComponent([ [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], ]); await waitForPromises(); @@ -104,6 +148,47 @@ describe('TokenAccess component', () => { expect(findToggle().props('value')).toBe(false); expect(findTokenDisabledAlert().exists()).toBe(true); }); + + describe('update ci job token scope', () => { + it('calls updateCIJobTokenScopeMutation mutation', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], + [updateCIJobTokenScopeMutation, updateScopeSuccessHandler], + ], + mountExtended, + ); + + await waitForPromises(); + + findToggle().vm.$emit('change', false); + + expect(updateScopeSuccessHandler).toHaveBeenCalledWith({ + input: { + fullPath: 'root/my-repo', + jobTokenScopeEnabled: false, + }, + }); + }); + + it('handles update scope error correctly', async () => { + createComponent( + [ + [getCIJobTokenScopeQuery, disabledJobTokenScopeHandler], + [updateCIJobTokenScopeMutation, failureHandler], + ], + mountExtended, + ); + + await waitForPromises(); + + findToggle().vm.$emit('change', true); + + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ message }); + }); + }); }); describe('add project', () => { @@ -111,7 +196,7 @@ describe('TokenAccess component', () => { createComponent( [ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], [addProjectCIJobTokenScopeMutation, addProjectSuccessHandler], ], mountExtended, @@ -133,8 +218,8 @@ describe('TokenAccess component', () => { createComponent( [ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], - [addProjectCIJobTokenScopeMutation, addProjectFailureHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], + [addProjectCIJobTokenScopeMutation, failureHandler], ], mountExtended, ); @@ -154,7 +239,7 @@ describe('TokenAccess component', () => { createComponent( [ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], [removeProjectCIJobTokenScopeMutation, removeProjectSuccessHandler], ], mountExtended, @@ -176,8 +261,8 @@ describe('TokenAccess component', () => { createComponent( [ [getCIJobTokenScopeQuery, enabledJobTokenScopeHandler], - [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScope], - [removeProjectCIJobTokenScopeMutation, removeProjectFailureHandler], + [getProjectsWithCIJobTokenScopeQuery, getProjectsWithScopeHandler], + [removeProjectCIJobTokenScopeMutation, failureHandler], ], mountExtended, ); diff --git a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap index bd40a968392..4077564486c 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap +++ b/spec/frontend/vue_merge_request_widget/components/states/__snapshots__/mr_widget_auto_merge_enabled_spec.js.snap @@ -2,7 +2,7 @@ exports[`MRWidgetAutoMergeEnabled template should have correct elements 1`] = ` <div - class="mr-widget-body media mr-widget-body-line-height-1 gl-line-height-normal" + class="mr-widget-body media gl-display-flex gl-align-items-center" > <div class="gl-w-6 gl-h-6 gl-display-flex gl-align-self-start gl-mr-3" @@ -61,7 +61,7 @@ exports[`MRWidgetAutoMergeEnabled template should have correct elements 1`] = ` class="gl-display-flex gl-align-items-flex-start" > <div - class="dropdown b-dropdown gl-new-dropdown gl-display-block gl-md-display-none! btn-group" + class="dropdown b-dropdown gl-dropdown gl-display-block gl-md-display-none! btn-group" lazy="" no-caret="" title="Options" @@ -87,7 +87,7 @@ exports[`MRWidgetAutoMergeEnabled template should have correct elements 1`] = ` </svg> <span - class="gl-new-dropdown-button-text gl-sr-only" + class="gl-dropdown-button-text gl-sr-only" > </span> diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js index 06ee017dee7..270a37f87e7 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_closed_spec.js @@ -1,9 +1,28 @@ -import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { shallowMount, mount } from '@vue/test-utils'; +import { useMockLocationHelper } from 'helpers/mock_window_location_helper'; +import waitForPromises from 'helpers/wait_for_promises'; + +import api from '~/api'; + +import showGlobalToast from '~/vue_shared/plugins/global_toast'; + import closedComponent from '~/vue_merge_request_widget/components/states/mr_widget_closed.vue'; import MrWidgetAuthorTime from '~/vue_merge_request_widget/components/mr_widget_author_time.vue'; import StateContainer from '~/vue_merge_request_widget/components/state_container.vue'; +import Actions from '~/vue_merge_request_widget/components/action_buttons.vue'; + +import { MR_WIDGET_CLOSED_REOPEN_FAILURE } from '~/vue_merge_request_widget/i18n'; + +jest.mock('~/api', () => ({ + updateMergeRequest: jest.fn(), +})); +jest.mock('~/vue_shared/plugins/global_toast'); + +useMockLocationHelper(); const MOCK_DATA = { + iid: 1, metrics: { mergedBy: {}, closedBy: { @@ -19,22 +38,39 @@ const MOCK_DATA = { }, targetBranchPath: '/twitter/flight/commits/so_long_jquery', targetBranch: 'so_long_jquery', + targetProjectId: 'twitter/flight', }; +function createComponent({ shallow = true, props = {} } = {}) { + const mounter = shallow ? shallowMount : mount; + + return mounter(closedComponent, { + propsData: { + mr: MOCK_DATA, + ...props, + }, + }); +} + +function findActions(wrapper) { + return wrapper.findComponent(StateContainer).findComponent(Actions); +} + +function findReopenActionButton(wrapper) { + return findActions(wrapper).find('button[data-testid="extension-actions-reopen-button"]'); +} + describe('MRWidgetClosed', () => { let wrapper; beforeEach(() => { - wrapper = shallowMount(closedComponent, { - propsData: { - mr: MOCK_DATA, - }, - }); + wrapper = createComponent(); }); afterEach(() => { - wrapper.destroy(); - wrapper = null; + if (wrapper) { + wrapper.destroy(); + } }); it('renders closed icon', () => { @@ -51,4 +87,93 @@ describe('MRWidgetClosed', () => { dateReadable: MOCK_DATA.metrics.readableClosedAt, }); }); + + describe('actions', () => { + describe('reopen', () => { + beforeEach(() => { + window.gon = { current_user_id: 1 }; + api.updateMergeRequest.mockResolvedValue(true); + wrapper = createComponent({ shallow: false }); + }); + + it('shows the "reopen" button', () => { + expect(wrapper.findComponent(StateContainer).props().actions.length).toBe(1); + expect(findReopenActionButton(wrapper).text()).toBe('Reopen'); + }); + + it('does not show widget actions when the user is not logged in', () => { + window.gon = {}; + + wrapper = createComponent(); + + expect(findActions(wrapper).exists()).toBe(false); + }); + + it('makes the reopen request with the correct MR information', async () => { + const reopenButton = findReopenActionButton(wrapper); + + reopenButton.trigger('click'); + await nextTick(); + + expect(api.updateMergeRequest).toHaveBeenCalledWith( + MOCK_DATA.targetProjectId, + MOCK_DATA.iid, + { state_event: 'reopen' }, + ); + }); + + it('shows "Reopening..." while the reopen network request is pending', async () => { + const reopenButton = findReopenActionButton(wrapper); + + api.updateMergeRequest.mockReturnValue(new Promise(() => {})); + + reopenButton.trigger('click'); + await nextTick(); + + expect(reopenButton.text()).toBe('Reopening...'); + }); + + it('shows "Refreshing..." when the reopen has succeeded', async () => { + const reopenButton = findReopenActionButton(wrapper); + + reopenButton.trigger('click'); + await waitForPromises(); + + expect(reopenButton.text()).toBe('Refreshing...'); + }); + + it('reloads the page when a reopen has succeeded', async () => { + const reopenButton = findReopenActionButton(wrapper); + + reopenButton.trigger('click'); + await waitForPromises(); + + expect(window.location.reload).toHaveBeenCalledTimes(1); + }); + + it('shows "Reopen" when a reopen request has failed', async () => { + const reopenButton = findReopenActionButton(wrapper); + + api.updateMergeRequest.mockRejectedValue(false); + + reopenButton.trigger('click'); + await waitForPromises(); + + expect(window.location.reload).not.toHaveBeenCalled(); + expect(reopenButton.text()).toBe('Reopen'); + }); + + it('requests a toast popup when a reopen request has failed', async () => { + const reopenButton = findReopenActionButton(wrapper); + + api.updateMergeRequest.mockRejectedValue(false); + + reopenButton.trigger('click'); + await waitForPromises(); + + expect(showGlobalToast).toHaveBeenCalledTimes(1); + expect(showGlobalToast).toHaveBeenCalledWith(MR_WIDGET_CLOSED_REOPEN_FAILURE); + }); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js index 407bd60b2b7..d34fc0c1e61 100644 --- a/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/states/mr_widget_ready_to_merge_spec.js @@ -812,14 +812,30 @@ describe('ReadyToMerge', () => { ); }); - it('shows the diverged commits text when the source branch is behind the target', () => { - createComponent({ - mr: { divergedCommitsCount: 9001, userPermissions: { canMerge: false }, canMerge: false }, + describe('shows the diverged commits text when the source branch is behind the target', () => { + it('when the MR can be merged', () => { + createComponent({ + mr: { divergedCommitsCount: 9001 }, + }); + + expect(wrapper.text()).toEqual( + expect.stringContaining('The source branch is 9001 commits behind the target branch'), + ); }); - expect(wrapper.text()).toEqual( - expect.stringContaining('The source branch is 9001 commits behind the target branch'), - ); + it('when the MR cannot be merged', () => { + createComponent({ + mr: { + divergedCommitsCount: 9001, + userPermissions: { canMerge: false }, + canMerge: false, + }, + }); + + expect(wrapper.text()).toEqual( + expect.stringContaining('The source branch is 9001 commits behind the target branch'), + ); + }); }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js new file mode 100644 index 00000000000..366ea113162 --- /dev/null +++ b/spec/frontend/vue_merge_request_widget/components/widget/action_buttons_spec.js @@ -0,0 +1,47 @@ +import { GlButton, GlDropdownItem } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Actions from '~/vue_merge_request_widget/components/widget/action_buttons.vue'; + +let wrapper; + +function factory(propsData = {}) { + wrapper = shallowMount(Actions, { + propsData: { ...propsData, widget: 'test' }, + }); +} + +describe('~/vue_merge_request_widget/components/widget/action_buttons.vue', () => { + afterEach(() => { + wrapper.destroy(); + }); + + describe('tertiaryButtons', () => { + it('renders buttons', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlButton)).toHaveLength(1); + }); + + it('calls action click handler', async () => { + const onClick = jest.fn(); + + factory({ + tertiaryButtons: [{ text: 'hello world', onClick }], + }); + + await wrapper.findComponent(GlButton).vm.$emit('click'); + + expect(onClick).toHaveBeenCalled(); + }); + + it('renders tertiary actions in dropdown', () => { + factory({ + tertiaryButtons: [{ text: 'hello world', href: 'https://gitlab.com', target: '_blank' }], + }); + + expect(wrapper.findAllComponents(GlDropdownItem)).toHaveLength(1); + }); + }); +}); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js index e4bee6b8652..791fe541eb6 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_content_row_spec.js @@ -1,7 +1,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import WidgetContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; import StatusIcon from '~/vue_merge_request_widget/components/widget/status_icon.vue'; -import ActionButtons from '~/vue_merge_request_widget/components/action_buttons.vue'; +import ActionButtons from '~/vue_merge_request_widget/components/widget/action_buttons.vue'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; describe('~/vue_merge_request_widget/components/widget/widget_content_row.vue', () => { @@ -76,7 +76,10 @@ describe('~/vue_merge_request_widget/components/widget/widget_content_row.vue', }, }); - expect(findHelpPopover().props('options')).toEqual({ title: 'Help popover title' }); + const popover = findHelpPopover(); + + expect(popover.props('options')).toEqual({ title: 'Help popover title' }); + expect(popover.props('icon')).toBe('information-o'); expect(wrapper.findByText('Help popover content').exists()).toBe(true); expect(wrapper.findByText('Learn more').attributes('href')).toBe('/path/to/docs'); expect(wrapper.findByText('Learn more').attributes('target')).toBe('_blank'); diff --git a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js index 9635e050e4d..4c93c88de16 100644 --- a/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js +++ b/spec/frontend/vue_merge_request_widget/components/widget/widget_spec.js @@ -4,7 +4,7 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import HelpPopover from '~/vue_shared/components/help_popover.vue'; import waitForPromises from 'helpers/wait_for_promises'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; -import ActionButtons from '~/vue_merge_request_widget/components/action_buttons.vue'; +import ActionButtons from '~/vue_merge_request_widget/components/widget/action_buttons.vue'; import Widget from '~/vue_merge_request_widget/components/widget/widget.vue'; import WidgetContentRow from '~/vue_merge_request_widget/components/widget/widget_content_row.vue'; @@ -24,6 +24,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { const findActionButtons = () => wrapper.findComponent(ActionButtons); const findToggleButton = () => wrapper.findByTestId('toggle-button'); const findHelpPopover = () => wrapper.findComponent(HelpPopover); + const findDynamicScroller = () => wrapper.findByTestId('dynamic-content-scroller'); const createComponent = ({ propsData, slots } = {}) => { wrapper = shallowMountExtended(Widget, { @@ -212,7 +213,10 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { }, }); - expect(findHelpPopover().props('options')).toEqual({ title: 'My help popover title' }); + const popover = findHelpPopover(); + + expect(popover.props('options')).toEqual({ title: 'My help popover title' }); + expect(popover.props('icon')).toBe('information-o'); expect(wrapper.findByText('Help popover content').exists()).toBe(true); expect(wrapper.findByText('Learn more').attributes('href')).toBe('/path/to/docs'); expect(wrapper.findByText('Learn more').attributes('target')).toBe('_blank'); @@ -370,7 +374,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { href: '#', target: '_blank', id: 'full-report-button', - text: 'Full Report', + text: 'Full report', }, ], }, @@ -388,7 +392,7 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { it('when full report is clicked it should call the respective telemetry event', async () => { expect(wrapper.vm.telemetryHub.fullReportClicked).not.toHaveBeenCalled(); - wrapper.findByText('Full Report').vm.$emit('click'); + wrapper.findByText('Full report').vm.$emit('click'); await nextTick(); expect(wrapper.vm.telemetryHub.fullReportClicked).toHaveBeenCalledTimes(1); }); @@ -408,4 +412,30 @@ describe('~/vue_merge_request_widget/components/widget/widget.vue', () => { expect(wrapper.vm.telemetryHub).toBe(null); }); }); + + describe('dynamic content', () => { + const content = [ + { + id: 'row-id', + header: ['This is a header', 'This is a subheader'], + text: 'Main text for the row', + subtext: 'Optional: Smaller sub-text to be displayed below the main text', + }, + ]; + + beforeEach(() => { + createComponent({ + propsData: { + isCollapsible: true, + content, + }, + }); + }); + + it('uses a dynamic scroller to show the items', async () => { + findToggleButton().vm.$emit('click'); + await waitForPromises(); + expect(findDynamicScroller().props('items')).toEqual(content); + }); + }); }); diff --git a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js index 05df66165dd..baef247b649 100644 --- a/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extensions/test_report/index_spec.js @@ -8,17 +8,17 @@ import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; -import httpStatusCodes from '~/lib/utils/http_status'; +import httpStatusCodes, { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; import TestCaseDetails from '~/pipelines/components/test_reports/test_case_details.vue'; -import { failedReport } from 'jest/reports/mock_data/mock_data'; -import mixedResultsTestReports from 'jest/reports/mock_data/new_and_fixed_failures_report.json'; -import newErrorsTestReports from 'jest/reports/mock_data/new_errors_report.json'; -import newFailedTestReports from 'jest/reports/mock_data/new_failures_report.json'; -import newFailedTestWithNullFilesReport from 'jest/reports/mock_data/new_failures_with_null_files_report.json'; -import successTestReports from 'jest/reports/mock_data/no_failures_report.json'; -import resolvedFailures from 'jest/reports/mock_data/resolved_failures.json'; -import recentFailures from 'jest/reports/mock_data/recent_failures_report.json'; +import { failedReport } from 'jest/ci/reports/mock_data/mock_data'; +import mixedResultsTestReports from 'jest/ci/reports/mock_data/new_and_fixed_failures_report.json'; +import newErrorsTestReports from 'jest/ci/reports/mock_data/new_errors_report.json'; +import newFailedTestReports from 'jest/ci/reports/mock_data/new_failures_report.json'; +import newFailedTestWithNullFilesReport from 'jest/ci/reports/mock_data/new_failures_with_null_files_report.json'; +import successTestReports from 'jest/ci/reports/mock_data/no_failures_report.json'; +import resolvedFailures from 'jest/ci/reports/mock_data/resolved_failures.json'; +import recentFailures from 'jest/ci/reports/mock_data/recent_failures_report.json'; const reportWithParsingErrors = failedReport; reportWithParsingErrors.suites[0].suite_errors = { @@ -82,7 +82,7 @@ describe('Test report extension', () => { }); it('with a 204 response, continues to display loading state', async () => { - mockApi(httpStatusCodes.NO_CONTENT, ''); + mockApi(HTTP_STATUS_NO_CONTENT, ''); createComponent(); await waitForPromises(); diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js index 9a72e4a086b..f0ebbb1a82e 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/index_spec.js @@ -1,4 +1,5 @@ import MockAdapter from 'axios-mock-adapter'; +import { GlBadge } from '@gitlab/ui'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { trimText } from 'helpers/text_helper'; import waitForPromises from 'helpers/wait_for_promises'; @@ -6,10 +7,10 @@ import axios from '~/lib/utils/axios_utils'; import extensionsContainer from '~/vue_merge_request_widget/components/extensions/container'; import { registerExtension } from '~/vue_merge_request_widget/components/extensions'; import codeQualityExtension from '~/vue_merge_request_widget/extensions/code_quality'; -import httpStatusCodes from '~/lib/utils/http_status'; +import httpStatusCodes, { HTTP_STATUS_NO_CONTENT } from '~/lib/utils/http_status'; +import { i18n } from '~/vue_merge_request_widget/extensions/code_quality/constants'; import { codeQualityResponseNewErrors, - codeQualityResponseResolvedErrors, codeQualityResponseResolvedAndNewErrors, codeQualityResponseNoErrors, } from './mock_data'; @@ -58,46 +59,55 @@ describe('Code Quality extension', () => { createComponent(); - expect(wrapper.text()).toBe('Code Quality test metrics results are being parsed'); + expect(wrapper.text()).toBe(i18n.loading); }); - it('displays failed loading text', async () => { - mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); - + it('with a 204 response, continues to display loading state', async () => { + mockApi(HTTP_STATUS_NO_CONTENT, ''); createComponent(); await waitForPromises(); - expect(wrapper.text()).toBe('Code Quality failed loading results'); + + expect(wrapper.text()).toBe(i18n.loading); }); - it('displays quality degradation', async () => { - mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors); + it('displays failed loading text', async () => { + mockApi(httpStatusCodes.INTERNAL_SERVER_ERROR); createComponent(); await waitForPromises(); - - expect(wrapper.text()).toBe('Code Quality degraded on 2 points.'); + expect(wrapper.text()).toBe(i18n.error); }); - it('displays quality improvement', async () => { - mockApi(httpStatusCodes.OK, codeQualityResponseResolvedErrors); + it('displays correct single Report', async () => { + mockApi(httpStatusCodes.OK, codeQualityResponseNewErrors); createComponent(); await waitForPromises(); - expect(wrapper.text()).toBe('Code Quality improved on 2 points.'); + expect(wrapper.text()).toBe( + i18n.degradedCopy(i18n.singularReport(codeQualityResponseNewErrors.new_errors)), + ); }); it('displays quality improvement and degradation', async () => { mockApi(httpStatusCodes.OK, codeQualityResponseResolvedAndNewErrors); createComponent(); - await waitForPromises(); - expect(wrapper.text()).toBe('Code Quality improved on 1 point and degraded on 1 point.'); + // replacing strong tags because they will not be found in the rendered text + expect(wrapper.text()).toBe( + i18n + .improvementAndDegradationCopy( + i18n.pluralReport(codeQualityResponseResolvedAndNewErrors.resolved_errors), + i18n.pluralReport(codeQualityResponseResolvedAndNewErrors.new_errors), + ) + .replace(/%{strong_start}/g, '') + .replace(/%{strong_end}/g, ''), + ); }); it('displays no detected errors', async () => { @@ -107,7 +117,7 @@ describe('Code Quality extension', () => { await waitForPromises(); - expect(wrapper.text()).toBe('No changes to Code Quality.'); + expect(wrapper.text()).toBe(i18n.noChanges); }); }); @@ -138,8 +148,17 @@ describe('Code Quality extension', () => { "Minor - Parsing error: 'return' outside of function in index.js:12", ); expect(text.resolvedError).toContain( - "Minor - Parsing error: 'return' outside of function in index.js:12", + "Minor - Parsing error: 'return' outside of function Fixed in index.js:12", ); }); + + it('adds fixed indicator (badge) when error is resolved', () => { + expect(findAllExtensionListItems().at(1).findComponent(GlBadge).exists()).toBe(true); + expect(findAllExtensionListItems().at(1).findComponent(GlBadge).text()).toEqual(i18n.fixed); + }); + + it('should not add fixed indicator (badge) when error is new', () => { + expect(findAllExtensionListItems().at(0).findComponent(GlBadge).exists()).toBe(false); + }); }); }); diff --git a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js index f5ad0ce7377..2e8e70f25db 100644 --- a/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js +++ b/spec/frontend/vue_merge_request_widget/extentions/code_quality/mock_data.js @@ -23,31 +23,6 @@ export const codeQualityResponseNewErrors = { }, }; -export const codeQualityResponseResolvedErrors = { - status: 'failed', - new_errors: [], - resolved_errors: [ - { - description: "Parsing error: 'return' outside of function", - severity: 'minor', - file_path: 'index.js', - line: 12, - }, - { - description: 'TODO found', - severity: 'minor', - file_path: '.gitlab-ci.yml', - line: 73, - }, - ], - existing_errors: [], - summary: { - total: 2, - resolved: 2, - errored: 0, - }, -}; - export const codeQualityResponseResolvedAndNewErrors = { status: 'failed', new_errors: [ diff --git a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js index 0f4637d18d9..683858b331d 100644 --- a/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js +++ b/spec/frontend/vue_merge_request_widget/mr_widget_options_spec.js @@ -22,6 +22,7 @@ import { import { SUCCESS } from '~/vue_merge_request_widget/components/deployment/constants'; import eventHub from '~/vue_merge_request_widget/event_hub'; import MrWidgetOptions from '~/vue_merge_request_widget/mr_widget_options.vue'; +import WidgetContainer from '~/vue_merge_request_widget/components/widget/app.vue'; import StatusIcon from '~/vue_merge_request_widget/components/extensions/status_icon.vue'; import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/graphql/queries/security_report_merge_request_download_paths.query.graphql'; import getStateQuery from '~/vue_merge_request_widget/queries/get_state.query.graphql'; @@ -62,6 +63,7 @@ describe('MrWidgetOptions', () => { let mock; const COLLABORATION_MESSAGE = 'Members who can merge are allowed to add commits'; + const findWidgetContainer = () => wrapper.findComponent(WidgetContainer); const findExtensionToggleButton = () => wrapper.find('[data-testid="widget-extension"] [data-testid="toggle-button"]'); const findExtensionLink = (linkHref) => @@ -1228,5 +1230,22 @@ describe('MrWidgetOptions', () => { expect(api.trackRedisCounterEvent).not.toHaveBeenCalled(); }); }); + + describe('widget container', () => { + afterEach(() => { + delete window.gon.features.refactorSecurityExtension; + }); + + it('should not be displayed when the refactor_security_extension feature flag is turned off', () => { + createComponent(); + expect(findWidgetContainer().exists()).toBe(false); + }); + + it('should be displayed when the refactor_security_extension feature flag is turned on', () => { + window.gon.features.refactorSecurityExtension = true; + createComponent(); + expect(findWidgetContainer().exists()).toBe(true); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap deleted file mode 100644 index bdf5ea23812..00000000000 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ /dev/null @@ -1,305 +0,0 @@ -// Jest Snapshot v1, https://goo.gl/fbAQLP - -exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` -<div - class="awards js-awards-block" -> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" - data-testid="award-button" - title="Ada, Leonardo, and Marie reacted with :thumbsup:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="thumbsup" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 3 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" - data-testid="award-button" - title="You, Ada, and Marie reacted with :thumbsdown:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="thumbsdown" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 3 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" - data-testid="award-button" - title="Ada and Jane reacted with :smile:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="smile" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 2 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" - data-testid="award-button" - title="You, Ada, Jane, and Leonardo reacted with :ok_hand:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="ok_hand" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 4 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" - data-testid="award-button" - title="You reacted with :cactus:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="cactus" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 1 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" - data-testid="award-button" - title="Marie reacted with :a:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="a" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 1 - </span> - </span> - </button> - <button - class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" - data-testid="award-button" - title="You reacted with :b:" - type="button" - > - <!----> - - <!----> - - <span - class="award-emoji-block" - data-testid="award-html" - > - <gl-emoji - data-name="b" - /> - </span> - - <span - class="gl-button-text" - > - - <span - class="js-counter" - > - 1 - </span> - </span> - </button> - - <div - class="award-menu-holder gl-my-2" - > - <div - class="emoji-picker" - data-testid="emoji-picker" - title="Add reaction" - > - <div - boundary="scrollParent" - class="dropdown b-dropdown gl-new-dropdown btn-group" - id="__BVID__13" - lazy="" - menu-class="dropdown-extended-height" - no-flip="" - > - <!----> - <button - aria-expanded="false" - aria-haspopup="true" - class="btn dropdown-toggle btn-default btn-md add-reaction-button btn-icon gl-relative! gl-button gl-dropdown-toggle btn-default-secondary" - id="__BVID__13__BV_toggle_" - type="button" - > - <span - class="gl-sr-only" - > - Add reaction - </span> - - <span - class="reaction-control-icon reaction-control-icon-neutral" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="slight-smile-icon" - role="img" - > - <use - href="#slight-smile" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smiley-icon" - role="img" - > - <use - href="#smiley" - /> - </svg> - </span> - - <span - class="reaction-control-icon reaction-control-icon-super-positive" - > - <svg - aria-hidden="true" - class="gl-icon s16" - data-testid="smile-icon" - role="img" - > - <use - href="#smile" - /> - </svg> - </span> - </button> - <ul - aria-labelledby="__BVID__13__BV_toggle_" - class="dropdown-menu dropdown-extended-height dropdown-menu-right" - role="menu" - tabindex="-1" - > - <!----> - </ul> - </div> - </div> - </div> -</div> -`; diff --git a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap index 87eaabf4e98..b7b43264330 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/memory_graph_spec.js.snap @@ -7,6 +7,7 @@ exports[`MemoryGraph Render chart should draw container with chart 1`] = ` > <gl-sparkline-chart-stub data="Nov 12 2019 19:17:33,2.87,Nov 12 2019 19:18:33,2.78,Nov 12 2019 19:19:33,2.78,Nov 12 2019 19:20:33,3.01" + gradient="" height="25" tooltiplabel="MB" /> diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js index 07c53c04723..f3fb840b270 100644 --- a/spec/frontend/vue_shared/components/actions_button_spec.js +++ b/spec/frontend/vue_shared/components/actions_button_spec.js @@ -1,6 +1,5 @@ -import { GlDropdown, GlDropdownDivider, GlButton } from '@gitlab/ui'; +import { GlDropdown, GlDropdownDivider, GlButton, GlTooltip } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; const TEST_ACTION = { @@ -32,7 +31,6 @@ describe('Actions button component', () => { function createComponent(props) { wrapper = shallowMount(ActionsButton, { propsData: { ...props }, - directives: { GlTooltip: createMockDirective() }, }); } @@ -40,15 +38,9 @@ describe('Actions button component', () => { wrapper.destroy(); }); - const getTooltip = (child) => { - const directiveBinding = getBinding(child.element, 'gl-tooltip'); - - return directiveBinding.value; - }; const findButton = () => wrapper.findComponent(GlButton); - const findButtonTooltip = () => getTooltip(findButton()); + const findTooltip = () => wrapper.findComponent(GlTooltip); const findDropdown = () => wrapper.findComponent(GlDropdown); - const findDropdownTooltip = () => getTooltip(findDropdown()); const parseDropdownItems = () => findDropdown() .findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub') @@ -88,8 +80,8 @@ describe('Actions button component', () => { expect(findButton().text()).toBe(TEST_ACTION.text); }); - it('should have tooltip', () => { - expect(findButtonTooltip()).toBe(TEST_ACTION.tooltip); + it('should not have tooltip', () => { + expect(findTooltip().exists()).toBe(false); }); it('should have attrs', () => { @@ -105,7 +97,18 @@ describe('Actions button component', () => { it('should have tooltip', () => { createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] }); - expect(findButtonTooltip()).toBe(TEST_TOOLTIP); + expect(findTooltip().text()).toBe(TEST_TOOLTIP); + }); + }); + + describe('when showActionTooltip is false', () => { + it('should not have tooltip', () => { + createComponent({ + actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }], + showActionTooltip: false, + }); + + expect(findTooltip().exists()).toBe(false); }); }); @@ -174,8 +177,8 @@ describe('Actions button component', () => { expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]); }); - it('should have tooltip value', () => { - expect(findDropdownTooltip()).toBe(TEST_ACTION.tooltip); + it('should not have tooltip value', () => { + expect(findTooltip().exists()).toBe(false); }); }); @@ -199,7 +202,7 @@ describe('Actions button component', () => { }); it('should have tooltip value', () => { - expect(findDropdownTooltip()).toBe(TEST_ACTION_2.tooltip); + expect(findTooltip().text()).toBe(TEST_ACTION_2.tooltip); }); }); }); diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 1c8cf726aca..c7f9d8fd8d5 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -38,7 +38,18 @@ const TEST_AWARDS = [ createAward(EMOJI_CACTUS, USERS.root), createAward(EMOJI_A, USERS.marie), createAward(EMOJI_B, USERS.root), + createAward(EMOJI_100, USERS.ada), ]; +const TEST_AWARDS_LENGTH = [ + EMOJI_SMILE, + EMOJI_OK, + EMOJI_THUMBSUP, + EMOJI_THUMBSDOWN, + EMOJI_A, + EMOJI_B, + EMOJI_CACTUS, + EMOJI_100, +].length; const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class'; const REACTION_CONTROL_CLASSES = [ @@ -88,10 +99,6 @@ describe('vue_shared/components/awards_list', () => { }); }); - it('matches snapshot', () => { - expect(wrapper.element).toMatchSnapshot(); - }); - it('shows awards in correct order', () => { expect(findAwardsData()).toEqual([ { @@ -108,6 +115,12 @@ describe('vue_shared/components/awards_list', () => { }, { classes: REACTION_CONTROL_CLASSES, + count: 1, + html: matchingEmojiTag(EMOJI_100), + title: `Ada reacted with :${EMOJI_100}:`, + }, + { + classes: REACTION_CONTROL_CLASSES, count: 2, html: matchingEmojiTag(EMOJI_SMILE), title: `Ada and Jane reacted with :${EMOJI_SMILE}:`, @@ -142,33 +155,23 @@ describe('vue_shared/components/awards_list', () => { it('with award clicked, it emits award', () => { expect(wrapper.emitted().award).toBeUndefined(); - findAwardButtons().at(2).vm.$emit('click'); + findAwardButtons().at(3).vm.$emit('click'); expect(wrapper.emitted().award).toEqual([[EMOJI_SMILE]]); }); - it('shows add award button', () => { - const btn = findAddAwardButton(); + it('with numeric award clicked, it emits award as is', () => { + expect(wrapper.emitted().award).toBeUndefined(); - expect(btn.exists()).toBe(true); - }); - }); + findAwardButtons().at(2).vm.$emit('click'); - describe('with numeric award', () => { - beforeEach(() => { - createComponent({ - awards: [createAward(EMOJI_100, USERS.ada)], - canAwardEmoji: true, - currentUserId: USERS.root.id, - }); + expect(wrapper.emitted().award).toEqual([[EMOJI_100]]); }); - it('when clicked, it emits award as number', () => { - expect(wrapper.emitted().award).toBeUndefined(); - - findAwardButtons().at(0).vm.$emit('click'); + it('shows add award button', () => { + const btn = findAddAwardButton(); - expect(wrapper.emitted().award).toEqual([[Number(EMOJI_100)]]); + expect(btn.exists()).toBe(true); }); }); @@ -210,7 +213,7 @@ describe('vue_shared/components/awards_list', () => { it('disables award buttons', () => { const buttons = findAwardButtons(); - expect(buttons.length).toBe(7); + expect(buttons.length).toBe(TEST_AWARDS_LENGTH); expect(buttons.wrappers.every((x) => x.classes('disabled'))).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js index f28805471f8..a37071aec9b 100644 --- a/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js +++ b/spec/frontend/vue_shared/components/content_viewer/content_viewer_spec.js @@ -1,7 +1,8 @@ import { mount } from '@vue/test-utils'; import { GREEN_BOX_IMAGE_URL } from 'spec/test_constants'; import ContentViewer from '~/vue_shared/components/content_viewer/content_viewer.vue'; -import '~/behaviors/markdown/render_gfm'; + +jest.mock('~/behaviors/markdown/render_gfm'); describe('ContentViewer', () => { 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 01ef52c6af9..0d329b6a065 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,11 +1,12 @@ import { GlSkeletonLoader } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; 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'; +jest.mock('~/behaviors/markdown/render_gfm'); + describe('MarkdownViewer', () => { let wrapper; let mock; @@ -26,7 +27,6 @@ describe('MarkdownViewer', () => { mock = new MockAdapter(axios); jest.spyOn(axios, 'post'); - jest.spyOn($.fn, 'renderGFM'); }); afterEach(() => { 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 1b9ca8e6092..b0e393bbf5e 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 @@ -13,7 +13,10 @@ import RecentSearchesService from '~/filtered_search/services/recent_searches_se import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import { FILTERED_SEARCH_TERM, - SortDirection, + SORT_DIRECTION, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, } from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; @@ -87,7 +90,7 @@ describe('FilteredSearchBarRoot', () => { 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]); - expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.descending); expect(wrapper.findComponent(GlButtonGroup).exists()).toBe(true); expect(wrapper.findComponent(GlButton).exists()).toBe(true); expect(wrapper.findComponent(GlDropdown).exists()).toBe(true); @@ -110,9 +113,9 @@ describe('FilteredSearchBarRoot', () => { describe('tokenSymbols', () => { it('returns a map containing type and symbols from `tokens` prop', () => { expect(wrapper.vm.tokenSymbols).toEqual({ - author_username: '@', - label_name: '~', - milestone_title: '%', + [TOKEN_TYPE_AUTHOR]: '@', + [TOKEN_TYPE_LABEL]: '~', + [TOKEN_TYPE_MILESTONE]: '%', }); }); }); @@ -120,9 +123,9 @@ describe('FilteredSearchBarRoot', () => { describe('tokenTitles', () => { it('returns a map containing type and title from `tokens` prop', () => { expect(wrapper.vm.tokenTitles).toEqual({ - author_username: 'Author', - label_name: 'Label', - milestone_title: 'Milestone', + [TOKEN_TYPE_AUTHOR]: 'Author', + [TOKEN_TYPE_LABEL]: 'Label', + [TOKEN_TYPE_MILESTONE]: 'Milestone', }); }); }); @@ -132,7 +135,7 @@ describe('FilteredSearchBarRoot', () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ - selectedSortDirection: SortDirection.ascending, + selectedSortDirection: SORT_DIRECTION.ascending, }); expect(wrapper.vm.sortDirectionIcon).toBe('sort-lowest'); @@ -142,7 +145,7 @@ describe('FilteredSearchBarRoot', () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ - selectedSortDirection: SortDirection.descending, + selectedSortDirection: SORT_DIRECTION.descending, }); expect(wrapper.vm.sortDirectionIcon).toBe('sort-highest'); @@ -154,7 +157,7 @@ describe('FilteredSearchBarRoot', () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ - selectedSortDirection: SortDirection.ascending, + selectedSortDirection: SORT_DIRECTION.ascending, }); expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Ascending'); @@ -164,7 +167,7 @@ describe('FilteredSearchBarRoot', () => { // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ - selectedSortDirection: SortDirection.descending, + selectedSortDirection: SORT_DIRECTION.descending, }); expect(wrapper.vm.sortDirectionTooltip).toBe('Sort direction: Descending'); @@ -272,11 +275,11 @@ describe('FilteredSearchBarRoot', () => { }); it('sets `selectedSortDirection` to be opposite of its current value', () => { - expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); + expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.descending); wrapper.vm.handleSortDirectionClick(); - expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.ascending); + expect(wrapper.vm.selectedSortDirection).toBe(SORT_DIRECTION.ascending); }); it('emits component event `onSort` with opposite of currently selected sort by value', () => { @@ -384,7 +387,7 @@ describe('FilteredSearchBarRoot', () => { // eslint-disable-next-line no-restricted-syntax wrapper.setData({ selectedSortOption: mockSortOptions[0], - selectedSortDirection: SortDirection.descending, + selectedSortDirection: SORT_DIRECTION.descending, recentSearches: mockHistoryItems, }); 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 a6713b7e7e4..b2f4c780f51 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,8 +1,28 @@ import { GlFilteredSearchToken } from '@gitlab/ui'; -import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; +import { mockLabels } from 'jest/sidebar/components/labels/labels_select_vue/mock_data'; import Api from '~/api'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import { + FILTERED_SEARCH_TERM, + OPERATORS_IS, + TOKEN_TITLE_AUTHOR, + TOKEN_TITLE_CONTACT, + TOKEN_TITLE_LABEL, + TOKEN_TITLE_MILESTONE, + TOKEN_TITLE_MY_REACTION, + TOKEN_TITLE_ORGANIZATION, + TOKEN_TITLE_RELEASE, + TOKEN_TITLE_SOURCE_BRANCH, + TOKEN_TYPE_AUTHOR, + TOKEN_TYPE_CONFIDENTIAL, + TOKEN_TYPE_CONTACT, + TOKEN_TYPE_LABEL, + TOKEN_TYPE_MILESTONE, + TOKEN_TYPE_MY_REACTION, + TOKEN_TYPE_ORGANIZATION, + TOKEN_TYPE_RELEASE, + TOKEN_TYPE_SOURCE_BRANCH, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; @@ -11,7 +31,7 @@ import ReleaseToken from '~/vue_shared/components/filtered_search_bar/tokens/rel import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; -export const mockAuthor1 = { +export const mockUser1 = { id: 1, name: 'Administrator', username: 'root', @@ -20,7 +40,7 @@ export const mockAuthor1 = { web_url: 'http://0.0.0.0:3000/root', }; -export const mockAuthor2 = { +export const mockUser2 = { id: 2, name: 'Claudio Beer', username: 'ericka_terry', @@ -29,7 +49,7 @@ export const mockAuthor2 = { web_url: 'http://0.0.0.0:3000/ericka_terry', }; -export const mockAuthor3 = { +export const mockUser3 = { id: 6, name: 'Shizue Hartmann', username: 'junita.weimann', @@ -38,7 +58,7 @@ export const mockAuthor3 = { web_url: 'http://0.0.0.0:3000/junita.weimann', }; -export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; +export const mockUsers = [mockUser1, mockUser2, mockUser3]; export const mockBranches = [{ name: 'Main' }, { name: 'v1.x' }, { name: 'my-Branch' }]; @@ -197,86 +217,86 @@ export const mockEmoji2 = { export const mockEmojis = [mockEmoji1, mockEmoji2]; export const mockBranchToken = { - type: 'source_branch', + type: TOKEN_TYPE_SOURCE_BRANCH, icon: 'branch', - title: 'Source Branch', + title: TOKEN_TITLE_SOURCE_BRANCH, unique: true, token: BranchToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchBranches: Api.branches.bind(Api), }; export const mockAuthorToken = { - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, icon: 'user', - title: 'Author', + title: TOKEN_TITLE_AUTHOR, unique: false, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: 'gitlab-org/gitlab-test', - fetchAuthors: Api.projectUsers.bind(Api), + fetchUsers: Api.projectUsers.bind(Api), }; export const mockLabelToken = { - type: 'label_name', + type: TOKEN_TYPE_LABEL, icon: 'labels', - title: 'Label', + title: TOKEN_TITLE_LABEL, unique: false, symbol: '~', token: LabelToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchLabels: () => Promise.resolve(mockLabels), }; export const mockMilestoneToken = { - type: 'milestone_title', + type: TOKEN_TYPE_MILESTONE, icon: 'clock', - title: 'Milestone', + title: TOKEN_TITLE_MILESTONE, unique: true, symbol: '%', token: MilestoneToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; export const mockReleaseToken = { - type: 'release', + type: TOKEN_TYPE_RELEASE, icon: 'rocket', - title: 'Release', + title: TOKEN_TITLE_RELEASE, token: ReleaseToken, fetchReleases: () => Promise.resolve(), }; export const mockReactionEmojiToken = { - type: 'my_reaction_emoji', + type: TOKEN_TYPE_MY_REACTION, icon: 'thumb-up', - title: 'My-Reaction', + title: TOKEN_TITLE_MY_REACTION, unique: true, token: EmojiToken, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, fetchEmojis: () => Promise.resolve(mockEmojis), }; export const mockCrmContactToken = { - type: 'crm_contact', - title: 'Contact', + type: TOKEN_TYPE_CONTACT, + title: TOKEN_TITLE_CONTACT, icon: 'user', token: CrmContactToken, isProject: false, fullPath: 'group', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }; export const mockCrmOrganizationToken = { - type: 'crm_contact', - title: 'Organization', + type: TOKEN_TYPE_ORGANIZATION, + title: TOKEN_TITLE_ORGANIZATION, icon: 'user', token: CrmOrganizationToken, isProject: false, fullPath: 'group', - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, unique: true, }; @@ -286,7 +306,7 @@ export const mockMembershipToken = { title: 'Membership', token: GlFilteredSearchToken, unique: true, - operators: OPERATOR_IS_ONLY, + operators: OPERATORS_IS, options: [ { value: 'exclude', title: 'Direct' }, { value: 'only', title: 'Inherited' }, @@ -301,7 +321,7 @@ export const mockMembershipTokenOptionsWithoutTitles = { export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken]; export const tokenValueAuthor = { - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, value: { data: 'root', operator: '=', @@ -309,7 +329,7 @@ export const tokenValueAuthor = { }; export const tokenValueLabel = { - type: 'label_name', + type: TOKEN_TYPE_LABEL, value: { operator: '=', data: 'bug', @@ -317,7 +337,7 @@ export const tokenValueLabel = { }; export const tokenValueMilestone = { - type: 'milestone_title', + type: TOKEN_TYPE_MILESTONE, value: { operator: '=', data: 'v1.0', @@ -333,7 +353,7 @@ export const tokenValueMembership = { }; export const tokenValueConfidential = { - type: 'confidential', + type: TOKEN_TYPE_CONFIDENTIAL, value: { operator: '=', data: true, @@ -341,23 +361,10 @@ export const tokenValueConfidential = { }; export const tokenValuePlain = { - type: 'filtered-search-term', + type: FILTERED_SEARCH_TERM, value: { data: 'foo' }, }; -export const tokenValueEmpty = { - type: 'filtered-search-term', - value: { data: '' }, -}; - -export const tokenValueEpic = { - type: 'epic_iid', - value: { - operator: '=', - data: '"foo"::&42', - }, -}; - export const mockHistoryItems = [ [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'], [tokenValueAuthor, 'si'], diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js index a0126c2bd63..164235e4bb9 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/base_token_spec.js @@ -12,12 +12,12 @@ import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import { mockRegularLabel, mockLabels, -} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; +} from 'jest/sidebar/components/labels/labels_select_vue/mock_data'; import { - DEFAULT_NONE_ANY, + OPTIONS_NONE_ANY, OPERATOR_IS, - OPERATOR_IS_NOT, + OPERATOR_NOT, } from '~/vue_shared/components/filtered_search_bar/constants'; import { getRecentlyUsedSuggestions, @@ -76,7 +76,7 @@ const mockProps = { active: false, suggestions: [], suggestionsLoading: false, - defaultSuggestions: DEFAULT_NONE_ANY, + defaultSuggestions: OPTIONS_NONE_ANY, getActiveTokenValue: (labels, data) => labels.find((label) => label.title === data), cursorPosition: 'start', }; @@ -301,13 +301,13 @@ describe('BaseToken', () => { describe('with default suggestions', () => { describe.each` - operator | shouldRenderFilteredSearchSuggestion - ${OPERATOR_IS} | ${true} - ${OPERATOR_IS_NOT} | ${false} + operator | shouldRenderFilteredSearchSuggestion + ${OPERATOR_IS} | ${true} + ${OPERATOR_NOT} | ${false} `('when operator is $operator', ({ shouldRenderFilteredSearchSuggestion, operator }) => { beforeEach(() => { const props = { - defaultSuggestions: DEFAULT_NONE_ANY, + defaultSuggestions: OPTIONS_NONE_ANY, value: { data: '', operator }, }; @@ -322,7 +322,7 @@ describe('BaseToken', () => { if (shouldRenderFilteredSearchSuggestion) { expect(filteredSearchSuggestions.map((c) => c.props())).toMatchObject( - DEFAULT_NONE_ANY.map((opt) => ({ value: opt.value })), + OPTIONS_NONE_ANY.map((opt) => ({ value: opt.value })), ); } else { expect(filteredSearchSuggestions).toHaveLength(0); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js index 05b42011fe1..311d5a13280 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -11,7 +11,7 @@ import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import { mockBranches, mockBranchToken } from '../mock_data'; @@ -112,7 +112,7 @@ describe('BranchToken', () => { }); describe('template', () => { - const defaultBranches = DEFAULT_NONE_ANY; + const defaultBranches = OPTIONS_NONE_ANY; async function showSuggestions() { const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); const suggestionsSegment = tokenSegments.at(2); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js index 5b744521979..7be7035a0f2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_contact_token_spec.js @@ -10,7 +10,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import CrmContactToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_contact_token.vue'; import searchCrmContactsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_contacts.query.graphql'; @@ -187,7 +187,7 @@ describe('CrmContactToken', () => { }); describe('template', () => { - const defaultContacts = DEFAULT_NONE_ANY; + const defaultContacts = OPTIONS_NONE_ANY; it('renders base-token component', () => { mountComponent({ @@ -250,7 +250,7 @@ describe('CrmContactToken', () => { expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + it('renders `OPTIONS_NONE_ANY` as default suggestions', () => { mountComponent({ active: true, config: { ...mockCrmContactToken }, @@ -262,8 +262,8 @@ describe('CrmContactToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); - DEFAULT_NONE_ANY.forEach((contact, index) => { + expect(suggestions).toHaveLength(OPTIONS_NONE_ANY.length); + OPTIONS_NONE_ANY.forEach((contact, index) => { expect(suggestions.at(index).text()).toBe(contact.text); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js index 3a3e96032e8..ecd3e8a04f1 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/crm_organization_token_spec.js @@ -10,7 +10,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import { getIdFromGraphQLId } from '~/graphql_shared/utils'; -import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import CrmOrganizationToken from '~/vue_shared/components/filtered_search_bar/tokens/crm_organization_token.vue'; import searchCrmOrganizationsQuery from '~/vue_shared/components/filtered_search_bar/queries/search_crm_organizations.query.graphql'; @@ -186,7 +186,7 @@ describe('CrmOrganizationToken', () => { }); describe('template', () => { - const defaultOrganizations = DEFAULT_NONE_ANY; + const defaultOrganizations = OPTIONS_NONE_ANY; it('renders base-token component', () => { mountComponent({ @@ -249,7 +249,7 @@ describe('CrmOrganizationToken', () => { expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + it('renders `OPTIONS_NONE_ANY` as default suggestions', () => { mountComponent({ active: true, config: { ...mockCrmOrganizationToken }, @@ -261,8 +261,8 @@ describe('CrmOrganizationToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); - DEFAULT_NONE_ANY.forEach((organization, index) => { + expect(suggestions).toHaveLength(OPTIONS_NONE_ANY.length); + OPTIONS_NONE_ANY.forEach((organization, index) => { expect(suggestions.at(index).text()).toBe(organization.text); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js index e8436d2db17..773df01ada7 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/emoji_token_spec.js @@ -12,9 +12,9 @@ import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { - DEFAULT_LABEL_NONE, - DEFAULT_LABEL_ANY, - DEFAULT_NONE_ANY, + OPTION_NONE, + OPTION_ANY, + OPTIONS_NONE_ANY, } from '~/vue_shared/components/filtered_search_bar/constants'; import EmojiToken from '~/vue_shared/components/filtered_search_bar/tokens/emoji_token.vue'; @@ -118,7 +118,7 @@ describe('EmojiToken', () => { }); describe('template', () => { - const defaultEmojis = DEFAULT_NONE_ANY; + const defaultEmojis = OPTIONS_NONE_ANY; beforeEach(async () => { wrapper = createComponent({ @@ -181,7 +181,7 @@ describe('EmojiToken', () => { expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_LABEL_NONE` and `DEFAULT_LABEL_ANY` as default suggestions', async () => { + it('renders `OPTION_NONE` and `OPTION_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, config: { ...mockReactionEmojiToken }, @@ -195,8 +195,8 @@ describe('EmojiToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); expect(suggestions).toHaveLength(2); - expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_NONE.text); - expect(suggestions.at(1).text()).toBe(DEFAULT_LABEL_ANY.text); + expect(suggestions.at(0).text()).toBe(OPTION_NONE.text); + expect(suggestions.at(1).text()).toBe(OPTION_ANY.text); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 8ca12afacec..9d96123c17f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -10,11 +10,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import { mockRegularLabel, mockLabels, -} from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; +} from 'jest/sidebar/components/labels/labels_select_vue/mock_data'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; @@ -141,7 +141,7 @@ describe('LabelToken', () => { }); describe('template', () => { - const defaultLabels = DEFAULT_NONE_ANY; + const defaultLabels = OPTIONS_NONE_ANY; beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); @@ -209,7 +209,7 @@ describe('LabelToken', () => { expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_NONE_ANY` as default suggestions', () => { + it('renders `OPTIONS_NONE_ANY` as default suggestions', () => { wrapper = createComponent({ active: true, config: { ...mockLabelToken }, @@ -221,8 +221,8 @@ describe('LabelToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(DEFAULT_NONE_ANY.length); - DEFAULT_NONE_ANY.forEach((label, index) => { + expect(suggestions).toHaveLength(OPTIONS_NONE_ANY.length); + OPTIONS_NONE_ANY.forEach((label, index) => { expect(suggestions.at(index).text()).toBe(label.text); }); }); 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/user_token_spec.js index 5371b9af475..32cb74d5f80 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/user_token_spec.js @@ -11,11 +11,11 @@ import waitForPromises from 'helpers/wait_for_promises'; import { createAlert } from '~/flash'; import axios from '~/lib/utils/axios_utils'; -import { DEFAULT_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import { OPTIONS_NONE_ANY } from '~/vue_shared/components/filtered_search_bar/constants'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; -import { mockAuthorToken, mockAuthors } from '../mock_data'; +import { mockAuthorToken, mockUsers } from '../mock_data'; jest.mock('~/flash'); const defaultStubs = { @@ -28,7 +28,7 @@ const defaultStubs = { }, }; -const mockPreloadedAuthors = [ +const mockPreloadedUsers = [ { id: 13, name: 'Administrator', @@ -46,7 +46,7 @@ function createComponent(options = {}) { data = {}, listeners = {}, } = options; - return mount(AuthorToken, { + return mount(UserToken, { propsData: { config, value, @@ -66,7 +66,7 @@ function createComponent(options = {}) { }); } -describe('AuthorToken', () => { +describe('UserToken', () => { const originalGon = window.gon; const currentUserLength = 1; let mock; @@ -85,40 +85,40 @@ describe('AuthorToken', () => { }); describe('methods', () => { - describe('fetchAuthors', () => { + describe('fetchUsers', () => { beforeEach(() => { wrapper = createComponent(); }); - it('calls `config.fetchAuthors` with provided searchTerm param', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors'); + it('calls `config.fetchUsers` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchUsers'); - getBaseToken().vm.$emit('fetch-suggestions', mockAuthors[0].username); + getBaseToken().vm.$emit('fetch-suggestions', mockUsers[0].username); - expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( + expect(wrapper.vm.config.fetchUsers).toHaveBeenCalledWith( mockAuthorToken.fetchPath, - mockAuthors[0].username, + mockUsers[0].username, ); }); - it('sets response to `authors` when request is succesful', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); + it('sets response to `users` when request is successful', () => { + jest.spyOn(wrapper.vm.config, 'fetchUsers').mockResolvedValue(mockUsers); getBaseToken().vm.$emit('fetch-suggestions', 'root'); return waitForPromises().then(() => { - expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockUsers); }); }); // TODO: rm when completed https://gitlab.com/gitlab-org/gitlab/-/issues/345756 describe('when there are null users presents', () => { - const mockAuthorsWithNullUser = mockAuthors.concat([null]); + const mockUsersWithNullUser = mockUsers.concat([null]); beforeEach(() => { jest - .spyOn(wrapper.vm.config, 'fetchAuthors') - .mockResolvedValue({ data: mockAuthorsWithNullUser }); + .spyOn(wrapper.vm.config, 'fetchUsers') + .mockResolvedValue({ data: mockUsersWithNullUser }); getBaseToken().vm.$emit('fetch-suggestions', 'root'); }); @@ -126,7 +126,7 @@ describe('AuthorToken', () => { describe('when res.data is present', () => { it('filters the successful response when null values are present', () => { return waitForPromises().then(() => { - expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockUsers); }); }); }); @@ -134,14 +134,14 @@ describe('AuthorToken', () => { describe('when response is an array', () => { it('filters the successful response when null values are present', () => { return waitForPromises().then(() => { - expect(getBaseToken().props('suggestions')).toEqual(mockAuthors); + expect(getBaseToken().props('suggestions')).toEqual(mockUsers); }); }); }); }); it('calls `createAlert` with flash error message when request fails', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + jest.spyOn(wrapper.vm.config, 'fetchUsers').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); @@ -153,7 +153,7 @@ describe('AuthorToken', () => { }); it('sets `loading` to false when request completes', async () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + jest.spyOn(wrapper.vm.config, 'fetchUsers').mockRejectedValue({}); getBaseToken().vm.$emit('fetch-suggestions', 'root'); @@ -174,23 +174,23 @@ describe('AuthorToken', () => { it('renders base-token component', () => { wrapper = createComponent({ - value: { data: mockAuthors[0].username }, - data: { authors: mockAuthors }, + value: { data: mockUsers[0].username }, + data: { users: mockUsers }, }); const baseTokenEl = getBaseToken(); expect(baseTokenEl.exists()).toBe(true); expect(baseTokenEl.props()).toMatchObject({ - suggestions: mockAuthors, - getActiveTokenValue: wrapper.vm.getActiveAuthor, + suggestions: mockUsers, + getActiveTokenValue: wrapper.vm.getActiveUser, }); }); it('renders token item when value is selected', async () => { wrapper = createComponent({ - value: { data: mockAuthors[0].username }, - data: { authors: mockAuthors }, + value: { data: mockUsers[0].username }, + data: { users: mockUsers }, stubs: { Portal: true }, }); @@ -201,20 +201,20 @@ describe('AuthorToken', () => { const tokenValue = tokenSegments.at(2); - expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockAuthors[0].avatar_url); - expect(tokenValue.text()).toBe(mockAuthors[0].name); // "Administrator" + expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockUsers[0].avatar_url); + expect(tokenValue.text()).toBe(mockUsers[0].name); // "Administrator" }); - it('renders token value with correct avatarUrl from author object', async () => { + it('renders token value with correct avatarUrl from user object', async () => { const getAvatarEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar); wrapper = createComponent({ - value: { data: mockAuthors[0].username }, + value: { data: mockUsers[0].username }, data: { - authors: [ + users: [ { - ...mockAuthors[0], + ...mockUsers[0], }, ], }, @@ -223,15 +223,15 @@ describe('AuthorToken', () => { await nextTick(); - expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + expect(getAvatarEl().props('src')).toBe(mockUsers[0].avatar_url); // setData usage is discouraged. See https://gitlab.com/groups/gitlab-org/-/epics/7330 for details // eslint-disable-next-line no-restricted-syntax wrapper.setData({ - authors: [ + users: [ { - ...mockAuthors[0], - avatarUrl: mockAuthors[0].avatar_url, + ...mockUsers[0], + avatarUrl: mockUsers[0].avatar_url, avatar_url: undefined, }, ], @@ -239,14 +239,14 @@ describe('AuthorToken', () => { await nextTick(); - expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + expect(getAvatarEl().props('src')).toBe(mockUsers[0].avatar_url); }); - it('renders provided defaultAuthors as suggestions', async () => { - const defaultAuthors = DEFAULT_NONE_ANY; + it('renders provided defaultUsers as suggestions', async () => { + const defaultUsers = OPTIONS_NONE_ANY; wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, defaultAuthors, preloadedAuthors: mockPreloadedAuthors }, + config: { ...mockAuthorToken, defaultUsers, preloadedUsers: mockPreloadedUsers }, stubs: { Portal: true }, }); @@ -254,16 +254,16 @@ describe('AuthorToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(defaultAuthors.length + currentUserLength); - defaultAuthors.forEach((label, index) => { + expect(suggestions).toHaveLength(defaultUsers.length + currentUserLength); + defaultUsers.forEach((label, index) => { expect(suggestions.at(index).text()).toBe(label.text); }); }); - it('does not render divider when no defaultAuthors', async () => { + it('does not render divider when no defaultUsers', async () => { wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, defaultAuthors: [] }, + config: { ...mockAuthorToken, defaultUsers: [] }, stubs: { Portal: true }, }); const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); @@ -274,10 +274,10 @@ describe('AuthorToken', () => { expect(wrapper.findComponent(GlDropdownDivider).exists()).toBe(false); }); - it('renders `DEFAULT_NONE_ANY` as default suggestions', async () => { + it('renders `OPTIONS_NONE_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, preloadedAuthors: mockPreloadedAuthors }, + config: { ...mockAuthorToken, preloadedUsers: mockPreloadedUsers }, stubs: { Portal: true }, }); @@ -286,8 +286,8 @@ describe('AuthorToken', () => { const suggestions = wrapper.findAllComponents(GlFilteredSearchSuggestion); expect(suggestions).toHaveLength(2 + currentUserLength); - expect(suggestions.at(0).text()).toBe(DEFAULT_NONE_ANY[0].text); - expect(suggestions.at(1).text()).toBe(DEFAULT_NONE_ANY[1].text); + expect(suggestions.at(0).text()).toBe(OPTIONS_NONE_ANY[0].text); + expect(suggestions.at(1).text()).toBe(OPTIONS_NONE_ANY[1].text); }); it('emits listeners in the base-token', () => { @@ -308,8 +308,8 @@ describe('AuthorToken', () => { active: true, config: { ...mockAuthorToken, - preloadedAuthors: mockPreloadedAuthors, - defaultAuthors: [], + preloadedUsers: mockPreloadedUsers, + defaultUsers: [], }, stubs: { Portal: true }, }); diff --git a/spec/frontend/vue_shared/components/group_select/group_select_spec.js b/spec/frontend/vue_shared/components/group_select/group_select_spec.js index f959d2225fa..c10b32c6acc 100644 --- a/spec/frontend/vue_shared/components/group_select/group_select_spec.js +++ b/spec/frontend/vue_shared/components/group_select/group_select_spec.js @@ -1,5 +1,5 @@ import { nextTick } from 'vue'; -import { GlListbox } from '@gitlab/ui'; +import { GlCollapsibleListbox } from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import axios from '~/lib/utils/axios_utils'; @@ -31,7 +31,7 @@ describe('GroupSelect', () => { const inputId = 'inputId'; // Finders - const findListbox = () => wrapper.findComponent(GlListbox); + const findListbox = () => wrapper.findComponent(GlCollapsibleListbox); const findInput = () => wrapper.findByTestId('input'); // Helpers diff --git a/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js new file mode 100644 index 00000000000..cb7262b15e3 --- /dev/null +++ b/spec/frontend/vue_shared/components/listbox_input/listbox_input_spec.js @@ -0,0 +1,132 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlListbox } from '@gitlab/ui'; +import ListboxInput from '~/vue_shared/components/listbox_input/listbox_input.vue'; + +describe('ListboxInput', () => { + let wrapper; + + // Props + const name = 'name'; + const defaultToggleText = 'defaultToggleText'; + const items = [ + { + text: 'Group 1', + options: [ + { text: 'Item 1', value: '1' }, + { text: 'Item 2', value: '2' }, + ], + }, + { + text: 'Group 2', + options: [{ text: 'Item 3', value: '3' }], + }, + ]; + + // Finders + const findGlListbox = () => wrapper.findComponent(GlListbox); + const findInput = () => wrapper.find('input'); + + const createComponent = (propsData) => { + wrapper = shallowMount(ListboxInput, { + propsData: { + name, + defaultToggleText, + items, + ...propsData, + }, + }); + }; + + describe('input attributes', () => { + beforeEach(() => { + createComponent(); + }); + + it('sets the input name', () => { + expect(findInput().attributes('name')).toBe(name); + }); + }); + + describe('toggle text', () => { + it('uses the default toggle text while no value is selected', () => { + createComponent(); + + expect(findGlListbox().props('toggleText')).toBe(defaultToggleText); + }); + + it("uses the selected option's text as the toggle text", () => { + const selectedOption = items[0].options[0]; + createComponent({ selected: selectedOption.value }); + + expect(findGlListbox().props('toggleText')).toBe(selectedOption.text); + }); + }); + + describe('input value', () => { + const selectedOption = items[0].options[0]; + + beforeEach(() => { + createComponent({ selected: selectedOption.value }); + jest.spyOn(findInput().element, 'dispatchEvent'); + }); + + it("sets the listbox's and input's values", () => { + const { value } = selectedOption; + + expect(findGlListbox().props('selected')).toBe(value); + expect(findInput().attributes('value')).toBe(value); + }); + + describe("when the listbox's value changes", () => { + const newSelectedOption = items[1].options[0]; + + beforeEach(() => { + findGlListbox().vm.$emit('select', newSelectedOption.value); + }); + + it('emits the `select` event', () => { + expect(wrapper.emitted('select')).toEqual([[newSelectedOption.value]]); + }); + }); + }); + + describe('search', () => { + beforeEach(() => { + createComponent(); + }); + + it('passes all items to GlListbox by default', () => { + createComponent(); + expect(findGlListbox().props('items')).toStrictEqual(items); + }); + + describe('with groups', () => { + beforeEach(() => { + createComponent(); + findGlListbox().vm.$emit('search', '1'); + }); + + it('passes only the items that match the search string', async () => { + expect(findGlListbox().props('items')).toStrictEqual([ + { + text: 'Group 1', + options: [{ text: 'Item 1', value: '1' }], + }, + ]); + }); + }); + + describe('with flat items', () => { + beforeEach(() => { + createComponent({ + items: items[0].options, + }); + findGlListbox().vm.$emit('search', '1'); + }); + + it('passes only the items that match the search string', async () => { + expect(findGlListbox().props('items')).toStrictEqual([{ text: 'Item 1', value: '1' }]); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/markdown/field_spec.js b/spec/frontend/vue_shared/components/markdown/field_spec.js index 50864a4bf25..285ea10c813 100644 --- a/spec/frontend/vue_shared/components/markdown/field_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_spec.js @@ -1,12 +1,14 @@ import { nextTick } from 'vue'; import AxiosMockAdapter from 'axios-mock-adapter'; -import $ from 'jquery'; import { TEST_HOST, FIXTURES_PATH } from 'spec/test_constants'; import axios from '~/lib/utils/axios_utils'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; import MarkdownFieldHeader from '~/vue_shared/components/markdown/header.vue'; import MarkdownToolbar from '~/vue_shared/components/markdown/toolbar.vue'; import { mountExtended } from 'helpers/vue_test_utils_helper'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +jest.mock('~/behaviors/markdown/render_gfm'); const markdownPreviewPath = `${TEST_HOST}/preview`; const markdownDocsPath = `${TEST_HOST}/docs`; @@ -138,15 +140,13 @@ describe('Markdown field component', () => { }); it('renders markdown preview and GFM', async () => { - const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); - previewLink = getPreviewLink(); previewLink.vm.$emit('click', { target: {} }); await axios.waitFor(markdownPreviewPath); expect(subject.find('.md-preview-holder').element.innerHTML).toContain(previewHTML); - expect(renderGFMSpy).toHaveBeenCalled(); + expect(renderGFM).toHaveBeenCalled(); }); it('calls video.pause() on comment input when isSubmitting is changed to true', async () => { diff --git a/spec/frontend/vue_shared/components/markdown/field_view_spec.js b/spec/frontend/vue_shared/components/markdown/field_view_spec.js index be1d840dd29..176ccfc5a69 100644 --- a/spec/frontend/vue_shared/components/markdown/field_view_spec.js +++ b/spec/frontend/vue_shared/components/markdown/field_view_spec.js @@ -1,10 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; import MarkdownFieldView from '~/vue_shared/components/markdown/field_view.vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +jest.mock('~/behaviors/markdown/render_gfm'); describe('Markdown Field View component', () => { - let renderGFMSpy; let wrapper; function createComponent() { @@ -12,7 +13,6 @@ describe('Markdown Field View component', () => { } beforeEach(() => { - renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); createComponent(); }); @@ -21,6 +21,6 @@ describe('Markdown Field View component', () => { }); it('processes rendering with GFM', () => { - expect(renderGFMSpy).toHaveBeenCalledTimes(1); + expect(renderGFM).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js index 625e67c7cc1..5f416db2676 100644 --- a/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js +++ b/spec/frontend/vue_shared/components/markdown/markdown_editor_spec.js @@ -171,6 +171,7 @@ describe('vue_shared/component/markdown/markdown_editor', () => { expect.objectContaining({ renderMarkdown: expect.any(Function), uploadsPath: window.uploads_path, + useBottomToolbar: false, markdown: value, }), ); diff --git a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js index 8edcb905096..2b311b75f85 100644 --- a/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js +++ b/spec/frontend/vue_shared/components/markdown_drawer/markdown_drawer_spec.js @@ -1,5 +1,5 @@ import { GlDrawer, GlAlert, GlSkeletonLoader } from '@gitlab/ui'; -import { nextTick } from 'vue'; +import Vue, { nextTick } from 'vue'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import MarkdownDrawer, { cache } from '~/vue_shared/components/markdown_drawer/markdown_drawer.vue'; import { getRenderedMarkdown } from '~/vue_shared/components/markdown_drawer/utils/fetch'; @@ -82,7 +82,10 @@ describe('MarkdownDrawer', () => { contentTop.mockClear(); }); - it(`computes offsetTop ${hasNavbar ? 'with' : 'without'} .navbar-gitlab`, () => { + it(`computes offsetTop ${hasNavbar ? 'with' : 'without'} .navbar-gitlab`, async () => { + wrapper.vm.getDrawerTop(); + await Vue.nextTick(); + expect(findDrawer().attributes('headerheight')).toBe(`${navbarHeight}px`); }); }); @@ -95,11 +98,11 @@ describe('MarkdownDrawer', () => { renderGLFMSpy = jest.spyOn(MarkdownDrawer.methods, 'renderGLFM'); fetchMarkdownSpy = jest.spyOn(MarkdownDrawer.methods, 'fetchMarkdown'); global.document.querySelector = jest.fn(() => ({ - getBoundingClientRect: jest.fn(() => ({ bottom: 100 })), dataset: { page: 'test', }, })); + contentTop.mockReturnValue(100); createComponent(); await nextTick(); }); @@ -118,12 +121,28 @@ describe('MarkdownDrawer', () => { expect(fetchMarkdownSpy).toHaveBeenCalledTimes(2); }); - it('for open triggers renderGLFM', async () => { + it('triggers renderGLFM in openDrawer', async () => { wrapper.vm.fetchMarkdown(); wrapper.vm.openDrawer(); await nextTick(); expect(renderGLFMSpy).toHaveBeenCalled(); }); + + it('triggers height calculation in openDrawer', async () => { + expect(findDrawer().attributes('headerheight')).toBe(`${0}px`); + wrapper.vm.fetchMarkdown(); + wrapper.vm.openDrawer(); + await nextTick(); + expect(findDrawer().attributes('headerheight')).toBe(`${100}px`); + }); + + it('triggers height calculation in toggleDrawer', async () => { + expect(findDrawer().attributes('headerheight')).toBe(`${0}px`); + wrapper.vm.fetchMarkdown(); + wrapper.vm.toggleDrawer(); + await nextTick(); + expect(findDrawer().attributes('headerheight')).toBe(`${100}px`); + }); }); describe('Markdown fetching', () => { diff --git a/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js b/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js index ff07b2cf838..adcf57b76a4 100644 --- a/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js +++ b/spec/frontend/vue_shared/components/markdown_drawer/utils/fetch_spec.js @@ -20,9 +20,9 @@ describe('utils/fetch', () => { }); describe.each` - axiosMock | type | toExpect - ${{ code: 200, res: { html: MOCK_HTML } }} | ${'success'} | ${MOCK_DRAWER_DATA} - ${{ code: 500, res: null }} | ${'error'} | ${MOCK_DRAWER_DATA_ERROR} + axiosMock | type | toExpect + ${{ code: 200, res: MOCK_HTML }} | ${'success'} | ${MOCK_DRAWER_DATA} + ${{ code: 500, res: null }} | ${'error'} | ${MOCK_DRAWER_DATA_ERROR} `('process markdown data', ({ axiosMock, type, toExpect }) => { describe(`if api fetch responds with ${type}`, () => { beforeEach(() => { diff --git a/spec/frontend/vue_shared/components/notes/system_note_spec.js b/spec/frontend/vue_shared/components/notes/system_note_spec.js index 98b04ede943..559f9bcb1a8 100644 --- a/spec/frontend/vue_shared/components/notes/system_note_spec.js +++ b/spec/frontend/vue_shared/components/notes/system_note_spec.js @@ -1,10 +1,12 @@ import MockAdapter from 'axios-mock-adapter'; import { mount } from '@vue/test-utils'; -import $ from 'jquery'; import waitForPromises from 'helpers/wait_for_promises'; import createStore from '~/notes/stores'; import IssueSystemNote from '~/vue_shared/components/notes/system_note.vue'; import axios from '~/lib/utils/axios_utils'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; + +jest.mock('~/behaviors/markdown/render_gfm'); describe('system note component', () => { let vm; @@ -75,11 +77,9 @@ describe('system note component', () => { }); it('should renderGFM onMount', () => { - const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); - createComponent(props); - expect(renderGFMSpy).toHaveBeenCalled(); + expect(renderGFM).toHaveBeenCalled(); }); it('renders outdated code lines', async () => { diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json index b42ec42d8b8..e5678c9a956 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/mocks/items_filters.json @@ -1,14 +1,20 @@ [ - { - "type": "assignee_username", - "value": { "data": "root2" } - }, - { - "type": "author_username", - "value": { "data": "root" } - }, - { - "type": "filtered-search-term", - "value": { "data": "bar" } + { + "type": "assignee", + "value": { + "data": "root2" } - ]
\ No newline at end of file + }, + { + "type": "author", + "value": { + "data": "root" + } + }, + { + "type": "filtered-search-term", + "value": { + "data": "bar" + } + } +]
\ No newline at end of file diff --git a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js index c0c3c4a9729..86a63db0d9e 100644 --- a/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js +++ b/spec/frontend/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs_spec.js @@ -2,9 +2,15 @@ import { GlAlert, GlBadge, GlPagination, GlTabs, GlTab } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import { nextTick } from 'vue'; import Tracking from '~/tracking'; -import { OPERATOR_IS_ONLY } from '~/vue_shared/components/filtered_search_bar/constants'; +import { + OPERATORS_IS, + TOKEN_TITLE_ASSIGNEE, + TOKEN_TITLE_AUTHOR, + TOKEN_TYPE_ASSIGNEE, + TOKEN_TYPE_AUTHOR, +} from '~/vue_shared/components/filtered_search_bar/constants'; import FilteredSearchBar from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; -import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import UserToken from '~/vue_shared/components/filtered_search_bar/tokens/user_token.vue'; import PageWrapper from '~/vue_shared/components/paginated_table_with_search_and_tabs/paginated_table_with_search_and_tabs.vue'; import mockItems from './mocks/items.json'; import mockFilters from './mocks/items_filters.json'; @@ -166,7 +172,7 @@ describe('AlertManagementEmptyState', () => { it('renders the filter set with the tokens according to the prop filterSearchTokens', () => { mountComponent({ - props: { filterSearchTokens: ['assignee_username'] }, + props: { filterSearchTokens: [TOKEN_TYPE_ASSIGNEE] }, }); expect(Filters().exists()).toBe(true); @@ -287,26 +293,26 @@ describe('AlertManagementEmptyState', () => { expect(Filters().props('searchInputPlaceholder')).toBe('Search or filter results…'); expect(Filters().props('tokens')).toEqual([ { - type: 'author_username', + type: TOKEN_TYPE_AUTHOR, icon: 'user', - title: 'Author', + title: TOKEN_TITLE_AUTHOR, unique: true, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: '/link', - fetchAuthors: expect.any(Function), + fetchUsers: expect.any(Function), }, { - type: 'assignee_username', + type: TOKEN_TYPE_ASSIGNEE, icon: 'user', - title: 'Assignee', + title: TOKEN_TITLE_ASSIGNEE, unique: true, symbol: '@', - token: AuthorToken, - operators: OPERATOR_IS_ONLY, + token: UserToken, + operators: OPERATORS_IS, fetchPath: '/link', - fetchAuthors: expect.any(Function), + fetchUsers: expect.any(Function), }, ]); expect(Filters().props('recentSearchesStorageKey')).toBe('items'); diff --git a/spec/frontend/vue_shared/components/registry/registry_search_spec.js b/spec/frontend/vue_shared/components/registry/registry_search_spec.js index fa7fabfaef6..591447a37c2 100644 --- a/spec/frontend/vue_shared/components/registry/registry_search_spec.js +++ b/spec/frontend/vue_shared/components/registry/registry_search_spec.js @@ -1,6 +1,6 @@ import { GlSorting, GlSortingItem, GlFilteredSearch } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; -import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants'; +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; import component from '~/vue_shared/components/registry/registry_search.vue'; describe('Registry Search', () => { diff --git a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js index bc1545014d7..79cacadd6af 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/mock_data.js +++ b/spec/frontend/vue_shared/components/runner_instructions/mock_data.js @@ -1,119 +1,5 @@ -export const mockGraphqlRunnerPlatforms = { - data: { - runnerPlatforms: { - nodes: [ - { - name: 'linux', - humanReadableName: 'Linux', - architectures: { - nodes: [ - { - name: 'amd64', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-amd64', - __typename: 'RunnerArchitecture', - }, - { - name: '386', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-386', - __typename: 'RunnerArchitecture', - }, - { - name: 'arm', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm', - __typename: 'RunnerArchitecture', - }, - { - name: 'arm64', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-linux-arm64', - __typename: 'RunnerArchitecture', - }, - ], - __typename: 'RunnerArchitectureConnection', - }, - __typename: 'RunnerPlatform', - }, - { - name: 'osx', - humanReadableName: 'macOS', - architectures: { - nodes: [ - { - name: 'amd64', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-darwin-amd64', - __typename: 'RunnerArchitecture', - }, - ], - __typename: 'RunnerArchitectureConnection', - }, - __typename: 'RunnerPlatform', - }, - { - name: 'windows', - humanReadableName: 'Windows', - architectures: { - nodes: [ - { - name: 'amd64', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-amd64.exe', - __typename: 'RunnerArchitecture', - }, - { - name: '386', - downloadLocation: - 'https://gitlab-runner-downloads.s3.amazonaws.com/latest/binaries/gitlab-runner-windows-386.exe', - __typename: 'RunnerArchitecture', - }, - ], - __typename: 'RunnerArchitectureConnection', - }, - __typename: 'RunnerPlatform', - }, - { - name: 'docker', - humanReadableName: 'Docker', - architectures: null, - __typename: 'RunnerPlatform', - }, - { - name: 'kubernetes', - humanReadableName: 'Kubernetes', - architectures: null, - __typename: 'RunnerPlatform', - }, - ], - __typename: 'RunnerPlatformConnection', - }, - project: { id: 'gid://gitlab/Project/1', __typename: 'Project' }, - group: null, - }, -}; +import mockGraphqlRunnerPlatforms from 'test_fixtures/graphql/runner_instructions/get_runner_platforms.query.graphql.json'; +import mockGraphqlInstructions from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.json'; +import mockGraphqlInstructionsWindows from 'test_fixtures/graphql/runner_instructions/get_runner_setup.query.graphql.windows.json'; -export const mockGraphqlInstructions = { - data: { - runnerSetup: { - installInstructions: - '# Install and run as service\nsudo gitlab-runner install --user=gitlab-runner --working-directory=/home/gitlab-runner\nsudo gitlab-runner start', - registerInstructions: - 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN', - __typename: 'RunnerSetup', - }, - }, -}; - -export const mockGraphqlInstructionsWindows = { - data: { - runnerSetup: { - installInstructions: - '# Windows runner, then run\n.gitlab-runner.exe install\n.gitlab-runner.exe start', - registerInstructions: - './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token $REGISTRATION_TOKEN', - __typename: 'RunnerSetup', - }, - }, -}; +export { mockGraphqlRunnerPlatforms, mockGraphqlInstructions, mockGraphqlInstructionsWindows }; diff --git a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js index 7c5fc63856a..ae9157591c5 100644 --- a/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js +++ b/spec/frontend/vue_shared/components/runner_instructions/runner_instructions_modal_spec.js @@ -113,10 +113,7 @@ describe('RunnerInstructionsModal component', () => { }); describe('should display default instructions', () => { - const { - installInstructions, - registerInstructions, - } = mockGraphqlInstructions.data.runnerSetup; + const { installInstructions } = mockGraphqlInstructions.data.runnerSetup; it('runner instructions are requested', () => { expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ @@ -128,53 +125,16 @@ describe('RunnerInstructionsModal component', () => { it('binary instructions are shown', async () => { const instructions = findBinaryInstructions().text(); - expect(instructions).toBe(installInstructions); + expect(instructions).toBe(installInstructions.trim()); }); it('register command is shown with a replaced token', async () => { const command = findRegisterCommand().text(); expect(command).toBe( - 'sudo gitlab-runner register --url http://gdk.test:3000/ --registration-token MY_TOKEN', + 'sudo gitlab-runner register --url http://localhost/ --registration-token MY_TOKEN', ); }); - - describe('when a register token is not shown', () => { - beforeEach(async () => { - createComponent({ props: { registrationToken: undefined } }); - await waitForPromises(); - }); - - it('register command is shown without a defined registration token', () => { - const instructions = findRegisterCommand().text(); - - expect(instructions).toBe(registerInstructions); - }); - }); - - describe('when providing a defaultPlatformName', () => { - beforeEach(async () => { - createComponent({ props: { defaultPlatformName: 'osx' } }); - await waitForPromises(); - }); - - it('runner instructions for the default selected platform are requested', () => { - expect(runnerSetupInstructionsHandler).toHaveBeenCalledWith({ - platform: 'osx', - architecture: 'amd64', - }); - }); - - it('sets the focus on the default selected platform', () => { - const findOsxPlatformButton = () => wrapper.findComponent({ ref: 'osx' }); - - findOsxPlatformButton().element.focus = jest.fn(); - - findModal().vm.$emit('shown'); - - expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); - }); - }); }); describe('after a platform and architecture are selected', () => { @@ -207,14 +167,14 @@ describe('RunnerInstructionsModal component', () => { it('other binary instructions are shown', () => { const instructions = findBinaryInstructions().text(); - expect(instructions).toBe(installInstructions); + expect(instructions).toBe(installInstructions.trim()); }); it('register command is shown', () => { const command = findRegisterCommand().text(); expect(command).toBe( - './gitlab-runner.exe register --url http://gdk.test:3000/ --registration-token MY_TOKEN', + './gitlab-runner.exe register --url http://localhost/ --registration-token MY_TOKEN', ); }); @@ -246,6 +206,43 @@ describe('RunnerInstructionsModal component', () => { }); }); + describe('when a register token is not known', () => { + beforeEach(async () => { + createComponent({ props: { registrationToken: undefined } }); + await waitForPromises(); + }); + + it('register command is shown without a defined registration token', () => { + const instructions = findRegisterCommand().text(); + + expect(instructions).toBe(mockGraphqlInstructions.data.runnerSetup.registerInstructions); + }); + }); + + describe('with a defaultPlatformName', () => { + beforeEach(async () => { + createComponent({ props: { defaultPlatformName: 'osx' } }); + await waitForPromises(); + }); + + it('runner instructions for the default selected platform are requested', () => { + expect(runnerSetupInstructionsHandler).toHaveBeenLastCalledWith({ + platform: 'osx', + architecture: 'amd64', + }); + }); + + it('sets the focus on the default selected platform', () => { + const findOsxPlatformButton = () => wrapper.findComponent({ ref: 'osx' }); + + findOsxPlatformButton().element.focus = jest.fn(); + + findModal().vm.$emit('shown'); + + expect(findOsxPlatformButton().element.focus).toHaveBeenCalled(); + }); + }); + describe('when the modal is not shown', () => { beforeEach(async () => { createComponent({ shown: false }); diff --git a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js index d720574ce6d..657bd59dac6 100644 --- a/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/components/chunk_spec.js @@ -1,10 +1,16 @@ +import { nextTick } from 'vue'; import { GlIntersectionObserver } from '@gitlab/ui'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import Chunk from '~/vue_shared/components/source_viewer/components/chunk.vue'; import ChunkLine from '~/vue_shared/components/source_viewer/components/chunk_line.vue'; -import { scrollToElement } from '~/lib/utils/common_utils'; +import LineHighlighter from '~/blob/line_highlighter'; -jest.mock('~/lib/utils/common_utils'); +const lineHighlighter = new LineHighlighter(); +jest.mock('~/blob/line_highlighter', () => + jest.fn().mockReturnValue({ + highlightHash: jest.fn(), + }), +); const DEFAULT_PROPS = { chunkIndex: 2, @@ -104,12 +110,14 @@ describe('Chunk component', () => { }); it('does not scroll to route hash if last chunk is not loaded', () => { - expect(scrollToElement).not.toHaveBeenCalled(); + expect(LineHighlighter).not.toHaveBeenCalled(); }); - it('scrolls to route hash if last chunk is loaded', () => { + it('scrolls to route hash if last chunk is loaded', async () => { createComponent({ totalChunks: DEFAULT_PROPS.chunkIndex + 1 }); - expect(scrollToElement).toHaveBeenCalledWith(hash, { behavior: 'auto' }); + await nextTick(); + expect(LineHighlighter).toHaveBeenCalledWith({ scrollBehavior: 'auto' }); + expect(lineHighlighter.highlightHash).toHaveBeenCalledWith(hash); }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js index a7b55d7332f..4d38e8ef25d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/link_dependencies_spec.js @@ -4,15 +4,16 @@ import gemspecLinker from '~/vue_shared/components/source_viewer/plugins/utils/g import gemfileLinker from '~/vue_shared/components/source_viewer/plugins/utils/gemfile_linker'; import podspecJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker'; import composerJsonLinker from '~/vue_shared/components/source_viewer/plugins/utils/composer_json_linker'; +import goSumLinker from '~/vue_shared/components/source_viewer/plugins/utils/go_sum_linker'; import linkDependencies from '~/vue_shared/components/source_viewer/plugins/link_dependencies'; import { PACKAGE_JSON_FILE_TYPE, - PACKAGE_JSON_CONTENT, GEMSPEC_FILE_TYPE, GODEPS_JSON_FILE_TYPE, GEMFILE_FILE_TYPE, PODSPEC_JSON_FILE_TYPE, COMPOSER_JSON_FILE_TYPE, + GO_SUM_FILE_TYPE, } from './mock_data'; jest.mock('~/vue_shared/components/source_viewer/plugins/utils/package_json_linker'); @@ -21,37 +22,31 @@ jest.mock('~/vue_shared/components/source_viewer/plugins/utils/godeps_json_linke jest.mock('~/vue_shared/components/source_viewer/plugins/utils/gemfile_linker'); jest.mock('~/vue_shared/components/source_viewer/plugins/utils/podspec_json_linker'); jest.mock('~/vue_shared/components/source_viewer/plugins/utils/composer_json_linker'); +jest.mock('~/vue_shared/components/source_viewer/plugins/utils/go_sum_linker'); describe('Highlight.js plugin for linking dependencies', () => { const hljsResultMock = { value: 'test' }; - it('calls packageJsonLinker for package_json file types', () => { - linkDependencies(hljsResultMock, PACKAGE_JSON_FILE_TYPE, PACKAGE_JSON_CONTENT); - expect(packageJsonLinker).toHaveBeenCalled(); - }); - - it('calls gemspecLinker for gemspec file types', () => { - linkDependencies(hljsResultMock, GEMSPEC_FILE_TYPE); - expect(gemspecLinker).toHaveBeenCalled(); - }); - - it('calls godepsJsonLinker for godeps_json file types', () => { - linkDependencies(hljsResultMock, GODEPS_JSON_FILE_TYPE); - expect(godepsJsonLinker).toHaveBeenCalled(); - }); - - it('calls gemfileLinker for gemfile file types', () => { - linkDependencies(hljsResultMock, GEMFILE_FILE_TYPE); - expect(gemfileLinker).toHaveBeenCalled(); - }); - - it('calls podspecJsonLinker for podspec_json file types', () => { - linkDependencies(hljsResultMock, PODSPEC_JSON_FILE_TYPE); - expect(podspecJsonLinker).toHaveBeenCalled(); - }); - - it('calls composerJsonLinker for composer_json file types', () => { - linkDependencies(hljsResultMock, COMPOSER_JSON_FILE_TYPE); - expect(composerJsonLinker).toHaveBeenCalled(); + describe.each` + fileType | linker + ${PACKAGE_JSON_FILE_TYPE} | ${packageJsonLinker} + ${GEMSPEC_FILE_TYPE} | ${gemspecLinker} + ${GODEPS_JSON_FILE_TYPE} | ${godepsJsonLinker} + ${GEMFILE_FILE_TYPE} | ${gemfileLinker} + ${PODSPEC_JSON_FILE_TYPE} | ${podspecJsonLinker} + ${COMPOSER_JSON_FILE_TYPE} | ${composerJsonLinker} + ${GO_SUM_FILE_TYPE} | ${goSumLinker} + `('$fileType file type', ({ fileType, linker }) => { + it('calls the correct linker', () => { + linkDependencies(hljsResultMock, fileType); + expect(linker).toHaveBeenCalled(); + }); + + it('does not call the linker for non-matching file types', () => { + const unknownFileType = 'unknown'; + + linkDependencies(hljsResultMock, unknownFileType); + expect(linker).not.toHaveBeenCalled(); + }); }); }); diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js index 5455479ec71..631baf19a2d 100644 --- a/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/mock_data.js @@ -32,3 +32,5 @@ export const PODSPEC_JSON_CONTENT = `{ }`; export const COMPOSER_JSON_FILE_TYPE = 'composer_json'; + +export const GO_SUM_FILE_TYPE = 'go_sum'; diff --git a/spec/frontend/vue_shared/components/source_viewer/plugins/utils/go_sum_linker_spec.js b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/go_sum_linker_spec.js new file mode 100644 index 00000000000..cc3ee41523f --- /dev/null +++ b/spec/frontend/vue_shared/components/source_viewer/plugins/utils/go_sum_linker_spec.js @@ -0,0 +1,14 @@ +import goSumLinker from '~/vue_shared/components/source_viewer/plugins/utils/go_sum_linker'; + +describe('Highlight.js plugin for linking go.sum dependencies', () => { + it('mutates the input value by wrapping dependencies and tags in anchors', () => { + const inputValue = + '<span class="">cloud.google.com/Go/bigquery v1.0.1/go.mod h1:i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=</span>'; + const outputValue = + '<span class=""><a href="https://pkg.go.dev/cloud.google.com/go/bigquery" target="_blank" rel="nofollow noreferrer noopener">cloud.google.com/Go/bigquery</a> v1.0.1/go.mod h1:<a href="https://sum.golang.org/lookup/cloud.google.com/go/bigquery@v1.0.1" target="_blank" rel="nofollow noreferrer noopener">i/xbL2UlR5RvWAURpBYZTtm/cXjCha9lbfbpx4poX+o=</a></span>'; + const hljsResultMock = { value: inputValue }; + + const output = goSumLinker(hljsResultMock); + expect(output).toBe(outputValue); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index 4188adc72a1..874796f653a 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -10,7 +10,7 @@ import searchUsersQueryOnMR from '~/graphql_shared/queries/users_search_with_mr_ import { IssuableType } from '~/issues/constants'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import SidebarParticipant from '~/sidebar/components/assignees/sidebar_participant.vue'; -import getIssueParticipantsQuery from '~/vue_shared/components/sidebar/queries/get_issue_participants.query.graphql'; +import getIssueParticipantsQuery from '~/sidebar/queries/get_issue_participants.query.graphql'; import UserSelect from '~/vue_shared/components/user_select/user_select.vue'; import { searchResponse, diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js index a0b868d1d52..3b0f0fe6e73 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -1,13 +1,20 @@ -import { GlModal } from '@gitlab/ui'; +import { GlButton, GlModal, GlPopover } from '@gitlab/ui'; import { nextTick } from 'vue'; import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; -import WebIdeLink, { i18n } from '~/vue_shared/components/web_ide_link.vue'; +import WebIdeLink, { + i18n, + PREFERRED_EDITOR_RESET_KEY, + PREFERRED_EDITOR_KEY, + KEY_WEB_IDE, +} from '~/vue_shared/components/web_ide_link.vue'; import ConfirmForkModal from '~/vue_shared/components/confirm_fork_modal.vue'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; import { stubComponent } from 'helpers/stub_component'; import { shallowMountExtended, mountExtended } from 'helpers/vue_test_utils_helper'; +import { useLocalStorageSpy } from 'helpers/local_storage_helper'; const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; @@ -79,9 +86,18 @@ const ACTION_PIPELINE_EDITOR = { }; describe('Web IDE link component', () => { + useLocalStorageSpy(); + let wrapper; - function createComponent(props, mountFn = shallowMountExtended) { + function createComponent( + props, + { + mountFn = shallowMountExtended, + glFeatures = {}, + userCalloutDismisserSlotProps = { dismiss: jest.fn() }, + } = {}, + ) { wrapper = mountFn(WebIdeLink, { propsData: { editUrl: TEST_EDIT_URL, @@ -91,6 +107,9 @@ describe('Web IDE link component', () => { forkPath, ...props, }, + provide: { + glFeatures, + }, stubs: { GlModal: stubComponent(GlModal, { template: ` @@ -100,10 +119,19 @@ describe('Web IDE link component', () => { <slot name="modal-footer"></slot> </div>`, }), + UserCalloutDismisser: stubComponent(UserCalloutDismisser, { + render() { + return this.$scopedSlots.default(userCalloutDismisserSlotProps); + }, + }), }, }); } + beforeEach(() => { + localStorage.setItem(PREFERRED_EDITOR_RESET_KEY, 'true'); + }); + afterEach(() => { wrapper.destroy(); }); @@ -112,6 +140,8 @@ describe('Web IDE link component', () => { const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); const findModal = () => wrapper.findComponent(GlModal); const findForkConfirmModal = () => wrapper.findComponent(ConfirmForkModal); + const findUserCalloutDismisser = () => wrapper.findComponent(UserCalloutDismisser); + const findNewWebIdeCalloutPopover = () => wrapper.findComponent(GlPopover); it.each([ { @@ -322,9 +352,9 @@ describe('Web IDE link component', () => { }); it.each(testActions)('opens the modal when the button is clicked', async ({ props }) => { - createComponent({ ...props, needsToFork: true }, mountExtended); + createComponent({ ...props, needsToFork: true }, { mountFn: mountExtended }); - await findActionsButton().trigger('click'); + await findActionsButton().findComponent(GlButton).trigger('click'); expect(findForkConfirmModal().props()).toEqual({ visible: true, @@ -377,7 +407,7 @@ describe('Web IDE link component', () => { gitpodEnabled: false, gitpodText, }, - mountExtended, + { mountFn: mountExtended }, ); findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); @@ -401,4 +431,178 @@ describe('Web IDE link component', () => { expect(findModal().exists()).toBe(false); }); }); + + describe('Web IDE callout', () => { + describe('vscode_web_ide feature flag is enabled and the edit button is not shown', () => { + let dismiss; + + beforeEach(() => { + dismiss = jest.fn(); + createComponent( + { + showEditButton: false, + }, + { + glFeatures: { vscodeWebIde: true }, + userCalloutDismisserSlotProps: { dismiss }, + }, + ); + }); + it('does not skip the user_callout_dismisser query', () => { + expect(findUserCalloutDismisser().props()).toEqual( + expect.objectContaining({ + skipQuery: false, + featureName: 'vscode_web_ide_callout', + }), + ); + }); + + it('mounts new web ide callout popover', () => { + expect(findNewWebIdeCalloutPopover().props()).toEqual( + expect.objectContaining({ + showCloseButton: '', + target: 'web-ide-link', + triggers: 'manual', + boundaryPadding: 80, + }), + ); + }); + + describe.each` + calloutStatus | shouldShowCallout | popoverVisibility | tooltipVisibility + ${'show'} | ${true} | ${true} | ${false} + ${'hide'} | ${false} | ${false} | ${true} + `( + 'when should $calloutStatus web ide callout', + ({ shouldShowCallout, popoverVisibility, tooltipVisibility }) => { + beforeEach(() => { + createComponent( + { + showEditButton: false, + }, + { + glFeatures: { vscodeWebIde: true }, + userCalloutDismisserSlotProps: { shouldShowCallout, dismiss }, + }, + ); + }); + + it(`popover visibility = ${popoverVisibility}`, () => { + expect(findNewWebIdeCalloutPopover().props().show).toBe(popoverVisibility); + }); + + it(`action button tooltip visibility = ${tooltipVisibility}`, () => { + expect(findActionsButton().props().showActionTooltip).toBe(tooltipVisibility); + }); + }, + ); + + it('dismisses the callout when popover close button is clicked', () => { + findNewWebIdeCalloutPopover().vm.$emit('close-button-clicked'); + + expect(dismiss).toHaveBeenCalled(); + }); + + it('dismisses the callout when action button is clicked', () => { + findActionsButton().vm.$emit('actionClicked'); + + expect(dismiss).toHaveBeenCalled(); + }); + }); + + describe.each` + featureFlag | showEditButton + ${false} | ${true} + ${true} | ${false} + ${false} | ${false} + `( + 'when vscode_web_ide=$featureFlag and showEditButton = $showEditButton', + ({ vscodeWebIde, showEditButton }) => { + let dismiss; + + beforeEach(() => { + dismiss = jest.fn(); + + createComponent( + { + showEditButton, + }, + { glFeatures: { vscodeWebIde }, userCalloutDismisserSlotProps: { dismiss } }, + ); + }); + + it('skips the user_callout_dismisser query', () => { + expect(findUserCalloutDismisser().props().skipQuery).toBe(true); + }); + + it('displays actions button tooltip', () => { + expect(findActionsButton().props().showActionTooltip).toBe(true); + }); + + it('mounts new web ide callout popover', () => { + expect(findNewWebIdeCalloutPopover().exists()).toBe(false); + }); + + it('does not dismiss the callout when action button is clicked', () => { + findActionsButton().vm.$emit('actionClicked'); + + expect(dismiss).not.toHaveBeenCalled(); + }); + }, + ); + }); + + describe('when vscode_web_ide feature flag is enabled', () => { + describe('when is not showing edit button', () => { + describe(`when ${PREFERRED_EDITOR_RESET_KEY} is unset`, () => { + beforeEach(() => { + localStorage.setItem.mockReset(); + localStorage.getItem.mockReturnValueOnce(null); + createComponent({ showEditButton: false }, { glFeatures: { vscodeWebIde: true } }); + }); + + it(`sets ${PREFERRED_EDITOR_KEY} local storage key to ${KEY_WEB_IDE}`, () => { + expect(localStorage.getItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); + expect(localStorage.setItem).toHaveBeenCalledWith(PREFERRED_EDITOR_KEY, KEY_WEB_IDE); + }); + + it(`sets ${PREFERRED_EDITOR_RESET_KEY} local storage key to true`, () => { + expect(localStorage.setItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY, true); + }); + + it(`selects ${KEY_WEB_IDE} as the preferred editor`, () => { + expect(findActionsButton().props().selectedKey).toBe(KEY_WEB_IDE); + }); + }); + + describe(`when ${PREFERRED_EDITOR_RESET_KEY} is set to true`, () => { + beforeEach(() => { + localStorage.setItem.mockReset(); + localStorage.getItem.mockReturnValueOnce('true'); + createComponent({ showEditButton: false }, { glFeatures: { vscodeWebIde: true } }); + }); + + it(`does not update the persisted preferred editor`, () => { + expect(localStorage.getItem).toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); + expect(localStorage.setItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); + }); + }); + }); + + describe('when is showing the edit button', () => { + it(`does not try to reset the ${PREFERRED_EDITOR_KEY}`, () => { + createComponent({ showEditButton: true }, { glFeatures: { vscodeWebIde: true } }); + + expect(localStorage.getItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); + }); + }); + }); + + describe('when vscode_web_ide feature flag is disabled', () => { + it(`does not try to reset the ${PREFERRED_EDITOR_KEY}`, () => { + createComponent({}, { glFeatures: { vscodeWebIde: false } }); + + expect(localStorage.getItem).not.toHaveBeenCalledWith(PREFERRED_EDITOR_RESET_KEY); + }); + }); }); diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js index f98e7a678f4..ff21b3bc356 100644 --- a/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_form_spec.js @@ -3,7 +3,7 @@ import { shallowMount } from '@vue/test-utils'; import IssuableForm from '~/vue_shared/issuable/create/components/issuable_form.vue'; import MarkdownField from '~/vue_shared/components/markdown/field.vue'; -import LabelsSelect from '~/vue_shared/components/sidebar/labels_select_vue/labels_select_root.vue'; +import LabelsSelect from '~/sidebar/components/labels/labels_select_vue/labels_select_root.vue'; const createComponent = ({ descriptionPreviewPath = '/gitlab-org/gitlab-shell/preview_markdown', diff --git a/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js new file mode 100644 index 00000000000..76b6efa15b6 --- /dev/null +++ b/spec/frontend/vue_shared/issuable/create/components/issuable_label_selector_spec.js @@ -0,0 +1,141 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import { + mockRegularLabel, + mockScopedLabel, +} from 'jest/sidebar/components/labels/labels_select_widget/mock_data'; +import IssuableLabelSelector from '~/vue_shared/issuable/create/components/issuable_label_selector.vue'; +import LabelsSelect from '~/sidebar/components/labels/labels_select_widget/labels_select_root.vue'; +import { + DropdownVariant, + LabelType, +} from '~/sidebar/components/labels/labels_select_widget/constants'; +import { WorkspaceType } from '~/issues/constants'; +import { __ } from '~/locale'; + +const allowLabelRemove = true; +const attrWorkspacePath = '/workspace-path'; +const fieldName = 'field_name[]'; +const fullPath = '/full-path'; +const labelsFilterBasePath = '/labels-filter-base-path'; +const initialLabels = []; +const issuableType = 'issue'; +const labelType = LabelType.project; +const variant = DropdownVariant.Embedded; +const workspaceType = WorkspaceType.project; + +describe('IssuableLabelSelector', () => { + let wrapper; + + const findTitle = () => wrapper.find('label').text().replace(/\s+/, ' '); + const findLabelIcon = () => wrapper.findComponent(GlIcon); + const findAllHiddenInputs = () => wrapper.findAll('input[type="hidden"]'); + const findLabelSelector = () => wrapper.findComponent(LabelsSelect); + + const createComponent = (injectedProps = {}) => { + return shallowMount(IssuableLabelSelector, { + provide: { + allowLabelRemove, + attrWorkspacePath, + fieldName, + fullPath, + labelsFilterBasePath, + initialLabels, + issuableType, + labelType, + variant, + workspaceType, + ...injectedProps, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + const expectTitleWithCount = (count) => { + const title = findTitle(); + + expect(title).toContain(__('Labels')); + expect(title).toContain(count.toString()); + }; + + describe('by default', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + it('has the selected labels count', () => { + expectTitleWithCount(0); + expect(findLabelIcon().props('name')).toBe('labels'); + }); + + it('has the label selector', () => { + expect(findLabelSelector().props()).toMatchObject({ + allowLabelRemove, + allowMultiselect: true, + showEmbeddedLabelsList: true, + fullPath, + attrWorkspacePath, + labelsFilterBasePath, + dropdownButtonText: __('Select label'), + labelsListTitle: __('Select label'), + footerCreateLabelTitle: __('Create project label'), + footerManageLabelTitle: __('Manage project labels'), + variant, + workspaceType, + labelCreateType: labelType, + selectedLabels: initialLabels, + }); + + expect(findLabelSelector().text()).toBe(__('None')); + }); + }); + + it('passing initial labels applies them to the form', () => { + wrapper = createComponent({ initialLabels: [mockRegularLabel, mockScopedLabel] }); + + expectTitleWithCount(2); + expect(findLabelSelector().props('selectedLabels')).toStrictEqual([ + mockRegularLabel, + mockScopedLabel, + ]); + expect(findAllHiddenInputs().wrappers.map((input) => input.element.value)).toStrictEqual([ + `${mockRegularLabel.id}`, + `${mockScopedLabel.id}`, + ]); + }); + + it('updates the selected labels on the `updateSelectedLabels` event', async () => { + wrapper = createComponent(); + + expectTitleWithCount(0); + expect(findLabelSelector().props('selectedLabels')).toStrictEqual([]); + expect(findAllHiddenInputs()).toHaveLength(0); + + await findLabelSelector().vm.$emit('updateSelectedLabels', { labels: [mockRegularLabel] }); + + expectTitleWithCount(1); + expect(findLabelSelector().props('selectedLabels')).toStrictEqual([mockRegularLabel]); + expect(findAllHiddenInputs().wrappers.map((input) => input.element.value)).toStrictEqual([ + `${mockRegularLabel.id}`, + ]); + }); + + it('updates the selected labels on the `onLabelRemove` event', async () => { + wrapper = createComponent({ initialLabels: [mockRegularLabel] }); + + expectTitleWithCount(1); + expect(findLabelSelector().props('selectedLabels')).toStrictEqual([mockRegularLabel]); + expect(findAllHiddenInputs().wrappers.map((input) => input.element.value)).toStrictEqual([ + `${mockRegularLabel.id}`, + ]); + + await findLabelSelector().vm.$emit('onLabelRemove', mockRegularLabel.id); + + expectTitleWithCount(0); + expect(findLabelSelector().props('selectedLabels')).toStrictEqual([]); + expect(findAllHiddenInputs()).toHaveLength(0); + }); +}); diff --git a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js index e1c6020686c..2fac004875a 100644 --- a/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js +++ b/spec/frontend/vue_shared/issuable/list/components/issuable_item_spec.js @@ -225,7 +225,7 @@ describe('IssuableItem', () => { }, }); - expect(wrapper.findByTestId('issuable-discussions').exists()).toBe(returnValue); + expect(wrapper.findByTestId('issuable-comments').exists()).toBe(returnValue); }, ); }); @@ -489,7 +489,7 @@ describe('IssuableItem', () => { it('renders discussions count', () => { wrapper = createComponent(); - const discussionsEl = wrapper.find('[data-testid="issuable-discussions"]'); + const discussionsEl = wrapper.findByTestId('issuable-comments'); expect(discussionsEl.exists()).toBe(true); expect(discussionsEl.findComponent(GlLink).attributes()).toMatchObject({ diff --git a/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js index f2211e5b2bb..ea58cc2baf5 100644 --- a/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js +++ b/spec/frontend/vue_shared/issuable/show/components/issuable_description_spec.js @@ -1,10 +1,12 @@ import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; import IssuableDescription from '~/vue_shared/issuable/show/components/issuable_description.vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { mockIssuable } from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + const createComponent = ({ issuable = mockIssuable, enableTaskList = true, @@ -16,11 +18,9 @@ const createComponent = ({ }); describe('IssuableDescription', () => { - let renderGFMSpy; let wrapper; beforeEach(() => { - renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); wrapper = createComponent(); }); @@ -30,17 +30,7 @@ describe('IssuableDescription', () => { describe('mounted', () => { it('calls `renderGFM`', () => { - expect(renderGFMSpy).toHaveBeenCalledTimes(1); - }); - }); - - describe('methods', () => { - describe('renderGFM', () => { - it('calls `renderGFM` on container element', () => { - wrapper.vm.renderGFM(); - - expect(renderGFMSpy).toHaveBeenCalled(); - }); + expect(renderGFM).toHaveBeenCalledTimes(1); }); }); diff --git a/spec/frontend/webhooks/components/__snapshots__/push_events_spec.js.snap b/spec/frontend/webhooks/components/__snapshots__/push_events_spec.js.snap index 3dbff024a6b..aec0f84cb82 100644 --- a/spec/frontend/webhooks/components/__snapshots__/push_events_spec.js.snap +++ b/spec/frontend/webhooks/components/__snapshots__/push_events_spec.js.snap @@ -141,7 +141,7 @@ exports[`Webhook push events form editor component Different push events rules w class="form-text text-muted custom-control" > <gl-sprintf-stub - message="Regex such as %{REGEX_CODE} is supported." + message="Regular expressions such as %{REGEX_CODE} are supported." /> </p> </gl-form-radio-group-stub> @@ -367,7 +367,7 @@ exports[`Webhook push events form editor component Different push events rules w class="form-text text-muted custom-control" > <gl-sprintf-stub - message="Regex such as %{REGEX_CODE} is supported." + message="Regular expressions such as %{REGEX_CODE} are supported." /> </p> </gl-form-radio-group-stub> diff --git a/spec/frontend/work_items/components/notes/system_note_spec.js b/spec/frontend/work_items/components/notes/system_note_spec.js new file mode 100644 index 00000000000..3e3b8bf65b2 --- /dev/null +++ b/spec/frontend/work_items/components/notes/system_note_spec.js @@ -0,0 +1,111 @@ +import { GlIcon } from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import { shallowMount } from '@vue/test-utils'; +import waitForPromises from 'helpers/wait_for_promises'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; +import WorkItemSystemNote from '~/work_items/components/notes/system_note.vue'; +import NoteHeader from '~/notes/components/note_header.vue'; +import axios from '~/lib/utils/axios_utils'; + +jest.mock('~/behaviors/markdown/render_gfm'); + +describe('system note component', () => { + let wrapper; + let props; + let mock; + + const findTimelineIcon = () => wrapper.findComponent(GlIcon); + const findSystemNoteMessage = () => wrapper.findComponent(NoteHeader); + const findOutdatedLineButton = () => + wrapper.findComponent('[data-testid="outdated-lines-change-btn"]'); + const findOutdatedLines = () => wrapper.findComponent('[data-testid="outdated-lines"]'); + + const createComponent = (propsData = {}) => { + wrapper = shallowMount(WorkItemSystemNote, { + propsData, + slots: { + 'extra-controls': + '<gl-button data-testid="outdated-lines-change-btn">Compare with last version</gl-button>', + }, + }); + }; + + beforeEach(() => { + props = { + note: { + id: '1424', + author: { + id: 1, + name: 'Root', + username: 'root', + state: 'active', + avatarUrl: 'path', + path: '/root', + }, + bodyHtml: '<p dir="auto">closed</p>', + systemNoteIconName: 'status_closed', + createdAt: '2017-08-02T10:51:58.559Z', + }, + }; + + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + }); + + it('should render a list item with correct id', () => { + createComponent(props); + + expect(wrapper.attributes('id')).toBe(`note_${props.note.id}`); + }); + + // Note: The test case below is to handle a use case related to vuex store but since this does not + // have a vuex store , disabling it now will be fixing it in the next iteration + // eslint-disable-next-line jest/no-disabled-tests + it.skip('should render target class is note is target note', () => { + createComponent(props); + + expect(wrapper.classes()).toContain('target'); + }); + + it('should render svg icon', () => { + createComponent(props); + + expect(findTimelineIcon().exists()).toBe(true); + }); + + // Redcarpet Markdown renderer wraps text in `<p>` tags + // we need to strip them because they break layout of commit lists in system notes: + // https://gitlab.com/gitlab-org/gitlab-foss/uploads/b07a10670919254f0220d3ff5c1aa110/jqzI.png + it('removes wrapping paragraph from note HTML', () => { + createComponent(props); + + expect(findSystemNoteMessage().html()).toContain('<span>closed</span>'); + }); + + it('should renderGFM onMount', () => { + createComponent(props); + + expect(renderGFM).toHaveBeenCalled(); + }); + + // eslint-disable-next-line jest/no-disabled-tests + it.skip('renders outdated code lines', async () => { + mock + .onGet('/outdated_line_change_path') + .reply(200, [ + { rich_text: 'console.log', type: 'new', line_code: '123', old_line: null, new_line: 1 }, + ]); + + createComponent({ + note: { ...props.note, outdated_line_change_path: '/outdated_line_change_path' }, + }); + + await findOutdatedLineButton().vm.$emit('click'); + await waitForPromises(); + + expect(findOutdatedLines().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_assignees_spec.js b/spec/frontend/work_items/components/work_item_assignees_spec.js index 7367212e49f..e85f62b881d 100644 --- a/spec/frontend/work_items/components/work_item_assignees_spec.js +++ b/spec/frontend/work_items/components/work_item_assignees_spec.js @@ -435,6 +435,20 @@ describe('WorkItemAssignees component', () => { expect(findTokenSelector().props('containerClass')).toBe('gl-shadow-none!'); }); + + it('calls the mutation for updating assignees with the correct input', async () => { + findTokenSelector().vm.$emit('input', [mockAssignees[1]]); + await waitForPromises(); + + expect(successUpdateWorkItemMutationHandler).toHaveBeenCalledWith({ + input: { + assigneesWidget: { + assigneeIds: [mockAssignees[1].id], + }, + id: 'gid://gitlab/WorkItem/1', + }, + }); + }); }); describe('tracking', () => { diff --git a/spec/frontend/work_items/components/work_item_description_rendered_spec.js b/spec/frontend/work_items/components/work_item_description_rendered_spec.js index 01ab7824975..0ab2546440b 100644 --- a/spec/frontend/work_items/components/work_item_description_rendered_spec.js +++ b/spec/frontend/work_items/components/work_item_description_rendered_spec.js @@ -1,9 +1,11 @@ import { shallowMount } from '@vue/test-utils'; -import $ from 'jquery'; import { nextTick } from 'vue'; import WorkItemDescriptionRendered from '~/work_items/components/work_item_description_rendered.vue'; +import { renderGFM } from '~/behaviors/markdown/render_gfm'; import { descriptionTextWithCheckboxes, descriptionHtmlWithCheckboxes } from '../mock_data'; +jest.mock('~/behaviors/markdown/render_gfm'); + describe('WorkItemDescription', () => { let wrapper; @@ -32,13 +34,11 @@ describe('WorkItemDescription', () => { }); it('renders gfm', async () => { - const renderGFMSpy = jest.spyOn($.fn, 'renderGFM'); - createComponent(); await nextTick(); - expect(renderGFMSpy).toHaveBeenCalled(); + expect(renderGFM).toHaveBeenCalled(); }); describe('with checkboxes', () => { diff --git a/spec/frontend/work_items/components/work_item_description_spec.js b/spec/frontend/work_items/components/work_item_description_spec.js index c79b049442d..05476ef5ca0 100644 --- a/spec/frontend/work_items/components/work_item_description_spec.js +++ b/spec/frontend/work_items/components/work_item_description_spec.js @@ -38,7 +38,7 @@ describe('WorkItemDescription', () => { const subscriptionHandler = jest.fn().mockResolvedValue(workItemDescriptionSubscriptionResponse); const workItemByIidResponseHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); let workItemResponseHandler; - let workItemsMvc2; + let workItemsMvc; const findMarkdownField = () => wrapper.findComponent(MarkdownField); const findMarkdownEditor = () => wrapper.findComponent(MarkdownEditor); @@ -46,7 +46,7 @@ describe('WorkItemDescription', () => { const findEditedAt = () => wrapper.findComponent(EditedAt); const editDescription = (newText) => { - if (workItemsMvc2) { + if (workItemsMvc) { return findMarkdownEditor().vm.$emit('input', newText); } return wrapper.find('textarea').setValue(newText); @@ -60,6 +60,7 @@ describe('WorkItemDescription', () => { canUpdate = true, workItemResponse = workItemResponseFactory({ canUpdate }), isEditing = false, + queryVariables = { id: workItemId }, fetchByIid = false, } = {}) => { workItemResponseHandler = jest.fn().mockResolvedValue(workItemResponse); @@ -75,14 +76,12 @@ describe('WorkItemDescription', () => { propsData: { workItemId: id, fullPath: 'test-project-path', - queryVariables: { - id: workItemId, - }, + queryVariables, fetchByIid, }, provide: { glFeatures: { - workItemsMvc2, + workItemsMvc, }, }, stubs: { @@ -104,11 +103,21 @@ describe('WorkItemDescription', () => { }); describe.each([true, false])( - 'editing description with workItemsMvc2 %workItemsMvc2Enabled', - (workItemsMvc2Enabled) => { + 'editing description with workItemsMvc %workItemsMvcEnabled', + (workItemsMvcEnabled) => { beforeEach(() => { beforeEach(() => { - workItemsMvc2 = workItemsMvc2Enabled; + workItemsMvc = workItemsMvcEnabled; + }); + }); + + it('has a subscription', async () => { + createComponent(); + + await waitForPromises(); + + expect(subscriptionHandler).toHaveBeenCalledWith({ + issuableId: workItemQueryResponse.data.workItem.id, }); }); @@ -275,6 +284,13 @@ describe('WorkItemDescription', () => { expect(workItemResponseHandler).not.toHaveBeenCalled(); expect(workItemByIidResponseHandler).toHaveBeenCalled(); }); + + it('skips calling the handlers when missing the needed queryVariables', async () => { + createComponent({ queryVariables: {}, fetchByIid: false }); + await waitForPromises(); + + expect(workItemResponseHandler).not.toHaveBeenCalled(); + }); }, ); }); diff --git a/spec/frontend/work_items/components/work_item_detail_modal_spec.js b/spec/frontend/work_items/components/work_item_detail_modal_spec.js index 4029e47c390..686641800b3 100644 --- a/spec/frontend/work_items/components/work_item_detail_modal_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_modal_spec.js @@ -86,7 +86,7 @@ describe('WorkItemDetailModal component', () => { isModal: true, workItemId: defaultPropsData.workItemId, workItemParentId: defaultPropsData.issueGid, - iid: null, + workItemIid: null, }); }); diff --git a/spec/frontend/work_items/components/work_item_detail_spec.js b/spec/frontend/work_items/components/work_item_detail_spec.js index 26777b57797..bbab45c7055 100644 --- a/spec/frontend/work_items/components/work_item_detail_spec.js +++ b/spec/frontend/work_items/components/work_item_detail_spec.js @@ -11,7 +11,7 @@ import Vue, { nextTick } from 'vue'; import VueApollo from 'vue-apollo'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; -import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import setWindowLocation from 'helpers/set_window_location_helper'; import WorkItemDetail from '~/work_items/components/work_item_detail.vue'; import WorkItemActions from '~/work_items/components/work_item_actions.vue'; import WorkItemDescription from '~/work_items/components/work_item_description.vue'; @@ -21,7 +21,7 @@ import WorkItemTitle from '~/work_items/components/work_item_title.vue'; import WorkItemAssignees from '~/work_items/components/work_item_assignees.vue'; import WorkItemLabels from '~/work_items/components/work_item_labels.vue'; import WorkItemMilestone from '~/work_items/components/work_item_milestone.vue'; -import WorkItemInformation from '~/work_items/components/work_item_information.vue'; +import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; import { i18n } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; @@ -31,7 +31,6 @@ import workItemAssigneesSubscription from '~/work_items/graphql/work_item_assign import workItemMilestoneSubscription from '~/work_items/graphql/work_item_milestone.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import updateWorkItemTaskMutation from '~/work_items/graphql/update_work_item_task.mutation.graphql'; -import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import { mockParent, workItemDatesSubscriptionResponse, @@ -40,11 +39,11 @@ import { workItemAssigneesSubscriptionResponse, workItemMilestoneSubscriptionResponse, projectWorkItemResponse, + objectiveType, } from '../mock_data'; describe('WorkItemDetail component', () => { let wrapper; - useLocalStorageSpy(); Vue.use(VueApollo); @@ -81,8 +80,7 @@ describe('WorkItemDetail component', () => { const findParentButton = () => findParent().findComponent(GlButton); const findCloseButton = () => wrapper.find('[data-testid="work-item-close"]'); const findWorkItemType = () => wrapper.find('[data-testid="work-item-type"]'); - const findWorkItemInformationAlert = () => wrapper.findComponent(WorkItemInformation); - const findLocalStorageSync = () => wrapper.findComponent(LocalStorageSync); + const findHierarchyTree = () => wrapper.findComponent(WorkItemTree); const createComponent = ({ isModal = false, @@ -92,9 +90,9 @@ describe('WorkItemDetail component', () => { subscriptionHandler = titleSubscriptionHandler, confidentialityMock = [updateWorkItemMutation, jest.fn()], error = undefined, + workItemsMvcEnabled = false, workItemsMvc2Enabled = false, fetchByIid = false, - iidPathQueryParam = undefined, } = {}) => { const handlers = [ [workItemQuery, handler], @@ -108,7 +106,7 @@ describe('WorkItemDetail component', () => { wrapper = shallowMount(WorkItemDetail, { apolloProvider: createMockApollo(handlers), - propsData: { isModal, workItemId, iid: '1' }, + propsData: { isModal, workItemId, workItemIid: '1' }, data() { return { updateInProgress, @@ -117,11 +115,14 @@ describe('WorkItemDetail component', () => { }, provide: { glFeatures: { + workItemsMvc: workItemsMvcEnabled, workItemsMvc2: workItemsMvc2Enabled, useIidInWorkItemsPath: fetchByIid, }, hasIssueWeightsFeature: true, hasIterationsFeature: true, + hasOkrsFeature: true, + hasIssuableHealthStatusFeature: true, projectNamespace: 'namespace', fullPath: 'group/project', }, @@ -129,18 +130,12 @@ describe('WorkItemDetail component', () => { WorkItemWeight: true, WorkItemIteration: true, }, - mocks: { - $route: { - query: { - iid_path: iidPathQueryParam, - }, - }, - }, }); }; afterEach(() => { wrapper.destroy(); + setWindowLocation(''); }); describe('when there is no `workItemId` prop', () => { @@ -406,9 +401,31 @@ describe('WorkItemDetail component', () => { expect(findWorkItemType().exists()).toBe(false); }); - it('sets the parent breadcrumb URL', () => { + it('shows parent breadcrumb icon', () => { + expect(findParentButton().props('icon')).toBe(mockParent.parent.workItemType.iconName); + }); + + it('sets the parent breadcrumb URL pointing to issue page when parent type is `Issue`', () => { expect(findParentButton().attributes().href).toBe('../../issues/5'); }); + + it('sets the parent breadcrumb URL based on parent webUrl when parent type is not `Issue`', async () => { + const mockParentObjective = { + parent: { + ...mockParent.parent, + workItemType: { + id: mockParent.parent.workItemType.id, + name: 'Objective', + iconName: 'issue-type-objective', + }, + }, + }; + const parentResponse = workItemResponseFactory(mockParentObjective); + createComponent({ handler: jest.fn().mockResolvedValue(parentResponse) }); + await waitForPromises(); + + expect(findParentButton().attributes().href).toBe(mockParentObjective.parent.webUrl); + }); }); }); @@ -563,7 +580,7 @@ describe('WorkItemDetail component', () => { `('$description', async ({ milestoneWidgetPresent, exists }) => { const response = workItemResponseFactory({ milestoneWidgetPresent }); const handler = jest.fn().mockResolvedValue(response); - createComponent({ handler, workItemsMvc2Enabled: true }); + createComponent({ handler }); await waitForPromises(); expect(findWorkItemMilestone().exists()).toBe(exists); @@ -594,24 +611,6 @@ describe('WorkItemDetail component', () => { }); }); - describe('work item information', () => { - beforeEach(() => { - createComponent(); - return waitForPromises(); - }); - - it('is visible when viewed for the first time and sets localStorage value', async () => { - localStorage.clear(); - expect(findWorkItemInformationAlert().exists()).toBe(true); - expect(findLocalStorageSync().props('value')).toBe(true); - }); - - it('is not visible after reading local storage input', async () => { - await findLocalStorageSync().vm.$emit('input', false); - expect(findWorkItemInformationAlert().exists()).toBe(false); - }); - }); - it('calls the global ID work item query when `useIidInWorkItemsPath` feature flag is false', async () => { createComponent(); await waitForPromises(); @@ -633,6 +632,8 @@ describe('WorkItemDetail component', () => { }); it('calls the IID work item query when `useIidInWorkItemsPath` feature flag is true and `iid_path` route parameter is present', async () => { + setWindowLocation(`?iid_path=true`); + createComponent({ fetchByIid: true, iidPathQueryParam: 'true' }); await waitForPromises(); @@ -642,4 +643,24 @@ describe('WorkItemDetail component', () => { iid: '1', }); }); + + describe('hierarchy widget', () => { + it('does not render children tree by default', async () => { + createComponent(); + await waitForPromises(); + + expect(findHierarchyTree().exists()).toBe(false); + }); + + it('renders children tree when work item is an Objective', async () => { + const objectiveWorkItem = workItemResponseFactory({ + workItemType: objectiveType, + }); + const handler = jest.fn().mockResolvedValue(objectiveWorkItem); + createComponent({ handler }); + await waitForPromises(); + + expect(findHierarchyTree().exists()).toBe(true); + }); + }); }); diff --git a/spec/frontend/work_items/components/work_item_information_spec.js b/spec/frontend/work_items/components/work_item_information_spec.js deleted file mode 100644 index 887c5f615e9..00000000000 --- a/spec/frontend/work_items/components/work_item_information_spec.js +++ /dev/null @@ -1,43 +0,0 @@ -import { mount } from '@vue/test-utils'; -import { GlAlert, GlLink } from '@gitlab/ui'; -import WorkItemInformation from '~/work_items/components/work_item_information.vue'; -import { helpPagePath } from '~/helpers/help_page_helper'; - -const createComponent = () => mount(WorkItemInformation); - -describe('Work item information alert', () => { - let wrapper; - const tasksHelpPath = helpPagePath('user/tasks'); - - const findAlert = () => wrapper.findComponent(GlAlert); - const findHelpLink = () => wrapper.findComponent(GlLink); - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - it('should be visible', () => { - expect(findAlert().exists()).toBe(true); - }); - - it('should emit `work-item-banner-dismissed` event when cross icon is clicked', () => { - findAlert().vm.$emit('dismiss'); - expect(wrapper.emitted('work-item-banner-dismissed').length).toBe(1); - }); - - it('the alert variant should be tip', () => { - expect(findAlert().props('variant')).toBe('tip'); - }); - - it('should have the correct text for title', () => { - expect(findAlert().props('title')).toBe(WorkItemInformation.i18n.tasksInformationTitle); - }); - - it('should have the correct link to work item link', () => { - expect(findHelpLink().exists()).toBe(true); - expect(findHelpLink().attributes('href')).toBe(tasksHelpPath); - }); -}); diff --git a/spec/frontend/work_items/components/work_item_labels_spec.js b/spec/frontend/work_items/components/work_item_labels_spec.js index 9f7659b3f8d..083bb5bc4a4 100644 --- a/spec/frontend/work_items/components/work_item_labels_spec.js +++ b/spec/frontend/work_items/components/work_item_labels_spec.js @@ -5,7 +5,7 @@ import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; import { mountExtended } from 'helpers/vue_test_utils_helper'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; -import labelSearchQuery from '~/vue_shared/components/sidebar/labels_select_widget/graphql/project_labels.query.graphql'; +import labelSearchQuery from '~/sidebar/components/labels/labels_select_widget/graphql/project_labels.query.graphql'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import workItemLabelsSubscription from 'ee_else_ce/work_items/graphql/work_item_labels.subscription.graphql'; import updateWorkItemMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; @@ -49,6 +49,7 @@ describe('WorkItemLabels component', () => { searchQueryHandler = successSearchQueryHandler, updateWorkItemMutationHandler = successUpdateWorkItemMutationHandler, fetchByIid = false, + queryVariables = { id: workItemId }, } = {}) => { const apolloProvider = createMockApollo([ [workItemQuery, workItemQueryHandler], @@ -63,9 +64,7 @@ describe('WorkItemLabels component', () => { workItemId, canUpdate, fullPath: 'test-project-path', - queryVariables: { - id: workItemId, - }, + queryVariables, fetchByIid, }, attachTo: document.body, @@ -251,4 +250,11 @@ describe('WorkItemLabels component', () => { expect(workItemQuerySuccess).not.toHaveBeenCalled(); expect(workItemByIidResponseHandler).toHaveBeenCalled(); }); + + it('skips calling the handlers when missing the needed queryVariables', async () => { + createComponent({ queryVariables: {}, fetchByIid: false }); + await waitForPromises(); + + expect(workItemQuerySuccess).not.toHaveBeenCalled(); + }); }); diff --git a/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js new file mode 100644 index 00000000000..5fbd8e7e1a7 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/okr_actions_split_button_spec.js @@ -0,0 +1,35 @@ +import { GlDropdownSectionHeader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; + +import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; + +const createComponent = () => { + return extendedWrapper(shallowMount(OkrActionsSplitButton)); +}; + +describe('RelatedItemsTree', () => { + let wrapper; + + beforeEach(() => { + wrapper = createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('OkrActionsSplitButton', () => { + describe('template', () => { + it('renders objective and key results sections', () => { + expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(0).text()).toContain( + 'Objective', + ); + + expect(wrapper.findAllComponents(GlDropdownSectionHeader).at(1).text()).toContain( + 'Key result', + ); + }); + }); + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js new file mode 100644 index 00000000000..47489d4796b --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_metadata_spec.js @@ -0,0 +1,67 @@ +import { GlLabel, GlAvatarsInline } from '@gitlab/ui'; + +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; + +import ItemMilestone from '~/issuable/components/issue_milestone.vue'; +import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; + +import { mockMilestone, mockAssignees, mockLabels } from '../../mock_data'; + +describe('WorkItemLinkChildMetadata', () => { + let wrapper; + + const createComponent = ({ + allowsScopedLabels = true, + milestone = mockMilestone, + assignees = mockAssignees, + labels = mockLabels, + } = {}) => { + wrapper = shallowMountExtended(WorkItemLinkChildMetadata, { + propsData: { + allowsScopedLabels, + milestone, + assignees, + labels, + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + it('renders milestone link button', () => { + const milestoneLink = wrapper.findComponent(ItemMilestone); + + expect(milestoneLink.exists()).toBe(true); + expect(milestoneLink.props('milestone')).toEqual(mockMilestone); + }); + + it('renders avatars for assignees', () => { + const avatars = wrapper.findComponent(GlAvatarsInline); + + expect(avatars.exists()).toBe(true); + expect(avatars.props()).toMatchObject({ + avatars: mockAssignees, + collapsed: true, + maxVisible: 2, + avatarSize: 24, + badgeTooltipProp: 'name', + badgeSrOnlyText: '', + }); + }); + + it('renders labels', () => { + const labels = wrapper.findAllComponents(GlLabel); + const mockLabel = mockLabels[0]; + + expect(labels).toHaveLength(mockLabels.length); + expect(labels.at(0).props()).toMatchObject({ + title: mockLabel.title, + backgroundColor: mockLabel.color, + description: mockLabel.description, + scoped: false, + }); + expect(labels.at(1).props('scoped')).toBe(true); // Second label is scoped + }); +}); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js index 1d5472a0473..73d498ad055 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_link_child_spec.js @@ -1,33 +1,73 @@ -import { GlButton, GlIcon } from '@gitlab/ui'; +import { GlIcon } from '@gitlab/ui'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import { createAlert } from '~/flash'; import RichTimestampTooltip from '~/vue_shared/components/rich_timestamp_tooltip.vue'; +import getWorkItemTreeQuery from '~/work_items/graphql/work_item_tree.query.graphql'; +import WorkItemLinkChildMetadata from '~/work_items/components/work_item_links/work_item_link_child_metadata.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; import WorkItemLinksMenu from '~/work_items/components/work_item_links/work_item_links_menu.vue'; +import WorkItemTreeChildren from '~/work_items/components/work_item_links/work_item_tree_children.vue'; +import { + WIDGET_TYPE_HIERARCHY, + TASK_TYPE_NAME, + WORK_ITEM_TYPE_VALUE_OBJECTIVE, +} from '~/work_items/constants'; -import { workItemTask, confidentialWorkItemTask, closedWorkItemTask } from '../../mock_data'; +import { + workItemTask, + workItemObjectiveWithChild, + workItemObjectiveNoMetadata, + confidentialWorkItemTask, + closedWorkItemTask, + mockMilestone, + mockAssignees, + mockLabels, + workItemHierarchyTreeResponse, + workItemHierarchyTreeFailureResponse, +} from '../../mock_data'; + +jest.mock('~/flash'); describe('WorkItemLinkChild', () => { const WORK_ITEM_ID = 'gid://gitlab/WorkItem/2'; let wrapper; + let getWorkItemTreeQueryHandler; + + Vue.use(VueApollo); const createComponent = ({ projectPath = 'gitlab-org/gitlab-test', canUpdate = true, issuableGid = WORK_ITEM_ID, childItem = workItemTask, + workItemType = TASK_TYPE_NAME, + apolloProvider = null, } = {}) => { + getWorkItemTreeQueryHandler = jest.fn().mockResolvedValue(workItemHierarchyTreeResponse); + wrapper = shallowMountExtended(WorkItemLinkChild, { + apolloProvider: + apolloProvider || createMockApollo([[getWorkItemTreeQuery, getWorkItemTreeQueryHandler]]), propsData: { projectPath, canUpdate, issuableGid, childItem, + workItemType, }, }); }; + beforeEach(() => { + createAlert.mockClear(); + }); + afterEach(() => { wrapper.destroy(); }); @@ -66,7 +106,7 @@ describe('WorkItemLinkChild', () => { beforeEach(() => { createComponent(); - titleEl = wrapper.findComponent(GlButton); + titleEl = wrapper.findByTestId('item-title'); }); it('renders item title', () => { @@ -76,16 +116,52 @@ describe('WorkItemLinkChild', () => { it.each` action | event | emittedEvent - ${'clicking'} | ${'click'} | ${'click'} ${'doing mouseover on'} | ${'mouseover'} | ${'mouseover'} ${'doing mouseout on'} | ${'mouseout'} | ${'mouseout'} `('$action item title emit `$emittedEvent` event', ({ event, emittedEvent }) => { + titleEl.vm.$emit(event); + + expect(wrapper.emitted(emittedEvent)).toEqual([[]]); + }); + + it('emits click event with correct parameters on clicking title', () => { const eventObj = { preventDefault: jest.fn(), }; - titleEl.vm.$emit(event, eventObj); + titleEl.vm.$emit('click', eventObj); - expect(wrapper.emitted(emittedEvent)).toEqual([[workItemTask.id, eventObj]]); + expect(wrapper.emitted('click')).toEqual([[eventObj]]); + }); + }); + + describe('item metadata', () => { + const findMetadataComponent = () => wrapper.findComponent(WorkItemLinkChildMetadata); + + beforeEach(() => { + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); + }); + + it('renders item metadata component when item has metadata present', () => { + const metadataEl = findMetadataComponent(); + expect(metadataEl.exists()).toBe(true); + expect(metadataEl.props()).toMatchObject({ + allowsScopedLabels: true, + milestone: mockMilestone, + assignees: mockAssignees, + labels: mockLabels, + }); + }); + + it('does not render item metadata component when item has no metadata present', () => { + createComponent({ + childItem: workItemObjectiveNoMetadata, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); + + expect(findMetadataComponent().exists()).toBe(false); }); }); @@ -116,7 +192,78 @@ describe('WorkItemLinkChild', () => { it('removeChild event on menu triggers `click-remove-child` event', () => { itemMenuEl.vm.$emit('removeChild'); - expect(wrapper.emitted('remove')).toEqual([[workItemTask.id]]); + expect(wrapper.emitted('removeChild')).toEqual([[workItemTask.id]]); + }); + }); + + describe('nested children', () => { + const findExpandButton = () => wrapper.findByTestId('expand-child'); + const findTreeChildren = () => wrapper.findComponent(WorkItemTreeChildren); + + beforeEach(() => { + getWorkItemTreeQueryHandler.mockClear(); + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + }); + }); + + it('displays expand button when item has children, children are not displayed by default', () => { + expect(findExpandButton().exists()).toBe(true); + expect(findTreeChildren().exists()).toBe(false); + }); + + it('fetches and displays children of item when clicking on expand button', async () => { + await findExpandButton().vm.$emit('click'); + + expect(findExpandButton().props('loading')).toBe(true); + await waitForPromises(); + + expect(getWorkItemTreeQueryHandler).toHaveBeenCalled(); + expect(findTreeChildren().exists()).toBe(true); + + const widgetHierarchy = workItemHierarchyTreeResponse.data.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_HIERARCHY, + ); + expect(findTreeChildren().props('children')).toEqual(widgetHierarchy.children.nodes); + }); + + it('does not fetch children if already fetched once while clicking expand button', async () => { + findExpandButton().vm.$emit('click'); // Expand for the first time + await waitForPromises(); + + expect(findTreeChildren().exists()).toBe(true); + + await findExpandButton().vm.$emit('click'); // Collapse + findExpandButton().vm.$emit('click'); // Expand again + await waitForPromises(); + + expect(getWorkItemTreeQueryHandler).toHaveBeenCalledTimes(1); // ensure children were fetched only once. + expect(findTreeChildren().exists()).toBe(true); + }); + + it('calls createAlert when children fetch request fails on clicking expand button', async () => { + const getWorkItemTreeQueryFailureHandler = jest + .fn() + .mockRejectedValue(workItemHierarchyTreeFailureResponse); + const apolloProvider = createMockApollo([ + [getWorkItemTreeQuery, getWorkItemTreeQueryFailureHandler], + ]); + + createComponent({ + childItem: workItemObjectiveWithChild, + workItemType: WORK_ITEM_TYPE_VALUE_OBJECTIVE, + apolloProvider, + }); + + findExpandButton().vm.$emit('click'); + await waitForPromises(); + + expect(createAlert).toHaveBeenCalledWith({ + captureError: true, + error: expect.any(Object), + message: 'Something went wrong while fetching children.', + }); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js index 071d5fb715a..bbe460a55ba 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_form_spec.js @@ -33,7 +33,7 @@ describe('WorkItemLinksForm', () => { typesResponse = projectWorkItemTypesQueryResponse, parentConfidential = false, hasIterationsFeature = false, - workItemsMvc2Enabled = false, + workItemsMvcEnabled = false, parentIteration = null, formType = FORM_TYPES.create, } = {}) => { @@ -52,7 +52,7 @@ describe('WorkItemLinksForm', () => { }, provide: { glFeatures: { - workItemsMvc2: workItemsMvc2Enabled, + workItemsMvc: workItemsMvcEnabled, }, projectPath: 'project/path', hasIterationsFeature, @@ -165,23 +165,8 @@ describe('WorkItemLinksForm', () => { }); describe('associate iteration with task', () => { - it('does not update iteration when mvc2 feature flag is not enabled', async () => { - await createComponent({ - hasIterationsFeature: true, - parentIteration: mockParentIteration, - }); - - findInput().vm.$emit('input', 'Create task test'); - - findForm().vm.$emit('submit', { - preventDefault: jest.fn(), - }); - await waitForPromises(); - expect(updateMutationResolver).not.toHaveBeenCalled(); - }); it('updates when parent has an iteration associated', async () => { await createComponent({ - workItemsMvc2Enabled: true, hasIterationsFeature: true, parentIteration: mockParentIteration, }); @@ -191,18 +176,23 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); - expect(updateMutationResolver).toHaveBeenCalledWith({ + expect(createMutationResolver).toHaveBeenCalledWith({ input: { - id: 'gid://gitlab/WorkItem/1', + title: 'Create task test', + projectPath: 'project/path', + workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/1', + }, + confidential: false, iterationWidget: { iterationId: mockParentIteration.id, }, }, }); }); - it('does not update when parent has no iteration associated', async () => { + it('does not send the iteration widget to mutation when parent has no iteration associated', async () => { await createComponent({ - workItemsMvc2Enabled: true, hasIterationsFeature: true, }); findInput().vm.$emit('input', 'Create task test'); @@ -211,7 +201,20 @@ describe('WorkItemLinksForm', () => { preventDefault: jest.fn(), }); await waitForPromises(); - expect(updateMutationResolver).not.toHaveBeenCalled(); + expect(createMutationResolver).not.toHaveBeenCalledWith({ + input: { + title: 'Create task test', + projectPath: 'project/path', + workItemTypeId: 'gid://gitlab/WorkItems::Type/3', + hierarchyWidget: { + parentId: 'gid://gitlab/WorkItem/1', + }, + confidential: false, + iterationWidget: { + iterationId: mockParentIteration.id, + }, + }, + }); }); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js index 66ce2c1becf..a61de78c623 100644 --- a/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js +++ b/spec/frontend/work_items/components/work_item_links/work_item_links_spec.js @@ -4,20 +4,25 @@ import VueApollo from 'vue-apollo'; import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import createMockApollo from 'helpers/mock_apollo_helper'; import waitForPromises from 'helpers/wait_for_promises'; +import setWindowLocation from 'helpers/set_window_location_helper'; +import { stubComponent } from 'helpers/stub_component'; import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; import issueDetailsQuery from 'ee_else_ce/work_items/graphql/get_issue_details.query.graphql'; import WorkItemLinks from '~/work_items/components/work_item_links/work_item_links.vue'; import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import WorkItemDetailModal from '~/work_items/components/work_item_detail_modal.vue'; import { FORM_TYPES } from '~/work_items/constants'; import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; import changeWorkItemParentMutation from '~/work_items/graphql/update_work_item.mutation.graphql'; import getWorkItemLinksQuery from '~/work_items/graphql/work_item_links.query.graphql'; +import workItemByIidQuery from '~/work_items/graphql/work_item_by_iid.query.graphql'; import { workItemHierarchyResponse, workItemHierarchyEmptyResponse, workItemHierarchyNoUpdatePermissionResponse, changeWorkItemParentMutationResponse, workItemQueryResponse, + projectWorkItemResponse, } from '../../mock_data'; Vue.use(VueApollo); @@ -55,6 +60,7 @@ const issueDetailsResponse = (confidential = false) => ({ }, }, }); +const showModal = jest.fn(); describe('WorkItemLinks', () => { let wrapper; @@ -71,6 +77,7 @@ describe('WorkItemLinks', () => { .mockResolvedValue(changeWorkItemParentMutationResponse); const childWorkItemQueryHandler = jest.fn().mockResolvedValue(workItemQueryResponse); + const childWorkItemByIidHandler = jest.fn().mockResolvedValue(projectWorkItemResponse); const createComponent = async ({ data = {}, @@ -78,6 +85,7 @@ describe('WorkItemLinks', () => { mutationHandler = mutationChangeParentHandler, issueDetailsQueryHandler = jest.fn().mockResolvedValue(issueDetailsResponse()), hasIterationsFeature = false, + fetchByIid = false, } = {}) => { mockApollo = createMockApollo( [ @@ -85,6 +93,7 @@ describe('WorkItemLinks', () => { [changeWorkItemParentMutation, mutationHandler], [workItemQuery, childWorkItemQueryHandler], [issueDetailsQuery, issueDetailsQueryHandler], + [workItemByIidQuery, childWorkItemByIidHandler], ], {}, { addTypename: true }, @@ -100,12 +109,22 @@ describe('WorkItemLinks', () => { projectPath: 'project/path', iid: '1', hasIterationsFeature, + glFeatures: { + useIidInWorkItemsPath: fetchByIid, + }, }, propsData: { issuableId: 1 }, apolloProvider: mockApollo, mocks: { $toast, }, + stubs: { + WorkItemDetailModal: stubComponent(WorkItemDetailModal, { + methods: { + show: showModal, + }, + }), + }, }); await waitForPromises(); @@ -130,6 +149,7 @@ describe('WorkItemLinks', () => { afterEach(() => { wrapper.destroy(); mockApollo = null; + setWindowLocation(''); }); it('is expanded by default', () => { @@ -237,7 +257,7 @@ describe('WorkItemLinks', () => { }); it('calls correct mutation with correct variables', async () => { - firstChild.vm.$emit('remove', firstChild.vm.childItem.id); + firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id); await waitForPromises(); @@ -252,7 +272,7 @@ describe('WorkItemLinks', () => { }); it('shows toast when mutation succeeds', async () => { - firstChild.vm.$emit('remove', firstChild.vm.childItem.id); + firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id); await waitForPromises(); @@ -264,56 +284,164 @@ describe('WorkItemLinks', () => { it('renders correct number of children after removal', async () => { expect(findWorkItemLinkChildItems()).toHaveLength(4); - firstChild.vm.$emit('remove', firstChild.vm.childItem.id); + firstChild.vm.$emit('removeChild', firstChild.vm.childItem.id); await waitForPromises(); expect(findWorkItemLinkChildItems()).toHaveLength(3); }); }); - describe('prefetching child items', () => { - let firstChild; - - beforeEach(async () => { - await createComponent(); + describe('when parent item is confidential', () => { + it('passes correct confidentiality status to form', async () => { + await createComponent({ + issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)), + }); + findToggleFormDropdown().vm.$emit('click'); + findToggleAddFormButton().vm.$emit('click'); + await nextTick(); - firstChild = findFirstWorkItemLinkChild(); + expect(findAddLinksForm().props('parentConfidential')).toBe(true); }); + }); - it('does not fetch the child work item before hovering work item links', () => { - expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + describe('when work item is fetched by id', () => { + describe('prefetching child items', () => { + let firstChild; + + beforeEach(async () => { + await createComponent(); + + firstChild = findFirstWorkItemLinkChild(); + }); + + it('does not fetch the child work item by id before hovering work item links', () => { + expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + }); + + it('fetches the child work item by id if link is hovered for 250+ ms', async () => { + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + + expect(childWorkItemQueryHandler).toHaveBeenCalledWith({ + id: 'gid://gitlab/WorkItem/2', + }); + }); + + it('does not fetch the child work item by id if link is hovered for less than 250 ms', async () => { + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); + jest.advanceTimersByTime(200); + firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id); + await waitForPromises(); + + expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + }); + + it('does not fetch work item by iid if link is hovered for 250+ ms', async () => { + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + + expect(childWorkItemByIidHandler).not.toHaveBeenCalled(); + }); }); - it('fetches the child work item if link is hovered for 250+ ms', async () => { - firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); - jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); - await waitForPromises(); + it('starts prefetching work item by id if URL contains work item id', async () => { + setWindowLocation('?work_item_id=5'); + await createComponent(); expect(childWorkItemQueryHandler).toHaveBeenCalledWith({ - id: 'gid://gitlab/WorkItem/2', + id: 'gid://gitlab/WorkItem/5', }); }); - it('does not fetch the child work item if link is hovered for less than 250 ms', async () => { - firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); - jest.advanceTimersByTime(200); - firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id); - await waitForPromises(); + it('does not open the modal if work item id URL parameter is not found in child items', async () => { + setWindowLocation('?work_item_id=555'); + await createComponent(); + + expect(showModal).not.toHaveBeenCalled(); + expect(wrapper.findComponent(WorkItemDetailModal).props('workItemId')).toBe(null); + }); + + it('opens the modal if work item id URL parameter is found in child items', async () => { + setWindowLocation('?work_item_id=2'); + await createComponent(); - expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + expect(showModal).toHaveBeenCalled(); + expect(wrapper.findComponent(WorkItemDetailModal).props('workItemId')).toBe( + 'gid://gitlab/WorkItem/2', + ); }); }); - describe('when parent item is confidential', () => { - it('passes correct confidentiality status to form', async () => { - await createComponent({ - issueDetailsQueryHandler: jest.fn().mockResolvedValue(issueDetailsResponse(true)), + describe('when work item is fetched by iid', () => { + describe('prefetching child items', () => { + let firstChild; + + beforeEach(async () => { + setWindowLocation('?iid_path=true'); + await createComponent({ fetchByIid: true }); + + firstChild = findFirstWorkItemLinkChild(); }); - findToggleFormDropdown().vm.$emit('click'); - findToggleAddFormButton().vm.$emit('click'); - await nextTick(); - expect(findAddLinksForm().props('parentConfidential')).toBe(true); + it('does not fetch the child work item by iid before hovering work item links', () => { + expect(childWorkItemByIidHandler).not.toHaveBeenCalled(); + }); + + it('fetches the child work item by iid if link is hovered for 250+ ms', async () => { + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + + expect(childWorkItemByIidHandler).toHaveBeenCalledWith({ + fullPath: 'project/path', + iid: '2', + }); + }); + + it('does not fetch the child work item by iid if link is hovered for less than 250 ms', async () => { + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); + jest.advanceTimersByTime(200); + firstChild.vm.$emit('mouseout', firstChild.vm.childItem.id); + await waitForPromises(); + + expect(childWorkItemByIidHandler).not.toHaveBeenCalled(); + }); + + it('does not fetch work item by id if link is hovered for 250+ ms', async () => { + firstChild.vm.$emit('mouseover', firstChild.vm.childItem.id); + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + await waitForPromises(); + + expect(childWorkItemQueryHandler).not.toHaveBeenCalled(); + }); }); + + it('starts prefetching work item by iid if URL contains work item id', async () => { + setWindowLocation('?work_item_iid=5&iid_path=true'); + await createComponent({ fetchByIid: true }); + + expect(childWorkItemByIidHandler).toHaveBeenCalledWith({ + iid: '5', + fullPath: 'project/path', + }); + }); + }); + + it('does not open the modal if work item iid URL parameter is not found in child items', async () => { + setWindowLocation('?work_item_iid=555&iid_path=true'); + await createComponent({ fetchByIid: true }); + + expect(showModal).not.toHaveBeenCalled(); + expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe(null); + }); + + it('opens the modal if work item iid URL parameter is found in child items', async () => { + setWindowLocation('?work_item_iid=2&iid_path=true'); + await createComponent({ fetchByIid: true }); + + expect(showModal).toHaveBeenCalled(); + expect(wrapper.findComponent(WorkItemDetailModal).props('workItemIid')).toBe('2'); }); }); diff --git a/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js new file mode 100644 index 00000000000..96211e12755 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_links/work_item_tree_spec.js @@ -0,0 +1,147 @@ +import Vue, { nextTick } from 'vue'; +import VueApollo from 'vue-apollo'; + +import createMockApollo from 'helpers/mock_apollo_helper'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import WorkItemTree from '~/work_items/components/work_item_links/work_item_tree.vue'; +import WorkItemLinksForm from '~/work_items/components/work_item_links/work_item_links_form.vue'; +import WorkItemLinkChild from '~/work_items/components/work_item_links/work_item_link_child.vue'; +import OkrActionsSplitButton from '~/work_items/components/work_item_links/okr_actions_split_button.vue'; +import workItemQuery from '~/work_items/graphql/work_item.query.graphql'; + +import { DEFAULT_DEBOUNCE_AND_THROTTLE_MS } from '~/lib/utils/constants'; + +import { + FORM_TYPES, + WORK_ITEM_TYPE_ENUM_OBJECTIVE, + WORK_ITEM_TYPE_ENUM_KEY_RESULT, +} from '~/work_items/constants'; +import { childrenWorkItems, workItemObjectiveWithChild } from '../../mock_data'; + +describe('WorkItemTree', () => { + let getWorkItemQueryHandler; + let wrapper; + + const findToggleButton = () => wrapper.findByTestId('toggle-tree'); + const findTreeBody = () => wrapper.findByTestId('tree-body'); + const findEmptyState = () => wrapper.findByTestId('tree-empty'); + const findToggleFormSplitButton = () => wrapper.findComponent(OkrActionsSplitButton); + const findForm = () => wrapper.findComponent(WorkItemLinksForm); + const findWorkItemLinkChildItems = () => wrapper.findAllComponents(WorkItemLinkChild); + + Vue.use(VueApollo); + + const createComponent = ({ + workItemType = 'Objective', + children = childrenWorkItems, + apolloProvider = null, + } = {}) => { + const mockWorkItemResponse = { + data: { + workItem: { + ...workItemObjectiveWithChild, + workItemType: { + ...workItemObjectiveWithChild.workItemType, + name: workItemType, + }, + }, + }, + }; + getWorkItemQueryHandler = jest.fn().mockResolvedValue(mockWorkItemResponse); + + wrapper = shallowMountExtended(WorkItemTree, { + apolloProvider: + apolloProvider || createMockApollo([[workItemQuery, getWorkItemQueryHandler]]), + propsData: { + workItemType, + workItemId: 'gid://gitlab/WorkItem/515', + children, + projectPath: 'test/project', + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('is expanded by default and displays Add button', () => { + expect(findToggleButton().props('icon')).toBe('chevron-lg-up'); + expect(findTreeBody().exists()).toBe(true); + expect(findToggleFormSplitButton().exists()).toBe(true); + }); + + it('collapses on click toggle button', async () => { + findToggleButton().vm.$emit('click'); + await nextTick(); + + expect(findToggleButton().props('icon')).toBe('chevron-lg-down'); + expect(findTreeBody().exists()).toBe(false); + }); + + it('displays empty state if there are no children', () => { + createComponent({ children: [] }); + expect(findEmptyState().exists()).toBe(true); + }); + + it('renders all hierarchy widget children', () => { + expect(findWorkItemLinkChildItems()).toHaveLength(4); + }); + + it('does not display form by default', () => { + expect(findForm().exists()).toBe(false); + }); + + it.each` + option | event | formType | childType + ${'New objective'} | ${'showCreateObjectiveForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} + ${'Existing objective'} | ${'showAddObjectiveForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_OBJECTIVE} + ${'New key result'} | ${'showCreateKeyResultForm'} | ${FORM_TYPES.create} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} + ${'Existing key result'} | ${'showAddKeyResultForm'} | ${FORM_TYPES.add} | ${WORK_ITEM_TYPE_ENUM_KEY_RESULT} + `( + 'when selecting $option from split button, renders the form passing $formType and $childType', + async ({ event, formType, childType }) => { + findToggleFormSplitButton().vm.$emit(event); + await nextTick(); + + expect(findForm().exists()).toBe(true); + expect(findForm().props('formType')).toBe(formType); + expect(findForm().props('childrenType')).toBe(childType); + }, + ); + + it('remove event on child triggers `removeChild` event', () => { + const firstChild = findWorkItemLinkChildItems().at(0); + firstChild.vm.$emit('removeChild', 'gid://gitlab/WorkItem/2'); + + expect(wrapper.emitted('removeChild')).toEqual([['gid://gitlab/WorkItem/2']]); + }); + + it.each` + description | workItemType | prefetch + ${'prefetches'} | ${'Issue'} | ${true} + ${'does not prefetch'} | ${'Objective'} | ${false} + `( + '$description work-item-link-child on mouseover when workItemType is "$workItemType"', + async ({ workItemType, prefetch }) => { + createComponent({ workItemType }); + const firstChild = findWorkItemLinkChildItems().at(0); + firstChild.vm.$emit('mouseover', childrenWorkItems[0]); + await nextTick(); + await waitForPromises(); + + jest.advanceTimersByTime(DEFAULT_DEBOUNCE_AND_THROTTLE_MS); + + if (prefetch) { + expect(getWorkItemQueryHandler).toHaveBeenCalled(); + } else { + expect(getWorkItemQueryHandler).not.toHaveBeenCalled(); + } + }, + ); +}); diff --git a/spec/frontend/work_items/components/work_item_milestone_spec.js b/spec/frontend/work_items/components/work_item_milestone_spec.js index 60ba2b55f76..5997de01274 100644 --- a/spec/frontend/work_items/components/work_item_milestone_spec.js +++ b/spec/frontend/work_items/components/work_item_milestone_spec.js @@ -179,6 +179,18 @@ describe('WorkItemMilestone component', () => { createComponent({ canUpdate: true }); }); + it('calls successSearchQueryHandler with variables when dropdown is opened', async () => { + showDropdown(); + await nextTick(); + + expect(successSearchQueryHandler).toHaveBeenCalledWith({ + first: 20, + fullPath: 'full-path', + state: 'active', + title: '', + }); + }); + it('shows the skeleton loader when the items are being fetched on click', async () => { showDropdown(); await nextTick(); diff --git a/spec/frontend/work_items/components/work_item_notes_spec.js b/spec/frontend/work_items/components/work_item_notes_spec.js new file mode 100644 index 00000000000..ed68d214fc9 --- /dev/null +++ b/spec/frontend/work_items/components/work_item_notes_spec.js @@ -0,0 +1,107 @@ +import { GlSkeletonLoader } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import waitForPromises from 'helpers/wait_for_promises'; +import SystemNote from '~/work_items/components/notes/system_note.vue'; +import WorkItemNotes from '~/work_items/components/work_item_notes.vue'; +import workItemNotesQuery from '~/work_items/graphql/work_item_notes.query.graphql'; +import workItemNotesByIidQuery from '~/work_items/graphql/work_item_notes_by_iid.query.graphql'; +import { WIDGET_TYPE_NOTES } from '~/work_items/constants'; +import { + mockWorkItemNotesResponse, + workItemQueryResponse, + mockWorkItemNotesByIidResponse, +} from '../mock_data'; + +const mockWorkItemId = workItemQueryResponse.data.workItem.id; +const mockNotesWidgetResponse = mockWorkItemNotesResponse.data.workItem.widgets.find( + (widget) => widget.type === WIDGET_TYPE_NOTES, +); + +const mockNotesByIidWidgetResponse = mockWorkItemNotesByIidResponse.data.workspace.workItems.nodes[0].widgets.find( + (widget) => widget.type === WIDGET_TYPE_NOTES, +); + +describe('WorkItemNotes component', () => { + let wrapper; + + Vue.use(VueApollo); + + const findAllSystemNotes = () => wrapper.findAllComponents(SystemNote); + const findActivityLabel = () => wrapper.find('label'); + const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader); + const workItemNotesQueryHandler = jest.fn().mockResolvedValue(mockWorkItemNotesResponse); + const workItemNotesByIidQueryHandler = jest + .fn() + .mockResolvedValue(mockWorkItemNotesByIidResponse); + + const createComponent = ({ workItemId = mockWorkItemId, fetchByIid = false } = {}) => { + wrapper = shallowMount(WorkItemNotes, { + apolloProvider: createMockApollo([ + [workItemNotesQuery, workItemNotesQueryHandler], + [workItemNotesByIidQuery, workItemNotesByIidQueryHandler], + ]), + propsData: { + workItemId, + queryVariables: { + id: workItemId, + }, + fullPath: 'test-path', + fetchByIid, + }, + provide: { + glFeatures: { + useIidInWorkItemsPath: fetchByIid, + }, + }, + }); + }; + + beforeEach(async () => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders activity label', () => { + expect(findActivityLabel().exists()).toBe(true); + }); + + describe('when notes are loading', () => { + it('renders skeleton loader', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('does not render system notes', () => { + expect(findAllSystemNotes().exists()).toBe(false); + }); + }); + + describe('when notes have been loaded', () => { + it('does not render skeleton loader', () => { + expect(findSkeletonLoader().exists()).toBe(true); + }); + + it('renders system notes to the length of the response', async () => { + await waitForPromises(); + expect(findAllSystemNotes()).toHaveLength(mockNotesWidgetResponse.discussions.nodes.length); + }); + }); + + describe('when the notes are fetched by `iid`', () => { + beforeEach(async () => { + createComponent({ workItemId: mockWorkItemId, fetchByIid: true }); + await waitForPromises(); + }); + + it('shows the notes list', () => { + expect(findAllSystemNotes()).toHaveLength( + mockNotesByIidWidgetResponse.discussions.nodes.length, + ); + }); + }); +}); diff --git a/spec/frontend/work_items/mock_data.js b/spec/frontend/work_items/mock_data.js index 635a1f326f8..850672b68d0 100644 --- a/spec/frontend/work_items/mock_data.js +++ b/spec/frontend/work_items/mock_data.js @@ -36,6 +36,16 @@ export const mockLabels = [ }, ]; +export const mockMilestone = { + __typename: 'Milestone', + id: 'gid://gitlab/Milestone/30', + title: 'v4.0', + state: 'active', + expired: false, + startDate: '2022-10-17', + dueDate: '2022-10-24', +}; + export const workItemQueryResponse = { data: { workItem: { @@ -85,11 +95,18 @@ export const workItemQueryResponse = { { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', + hasChildren: true, parent: { id: 'gid://gitlab/Issue/1', iid: '5', title: 'Parent title', confidential: false, + webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', + }, }, children: { nodes: [ @@ -97,6 +114,20 @@ export const workItemQueryResponse = { id: 'gid://gitlab/WorkItem/444', createdAt: '2022-08-03T12:41:54Z', closedAt: null, + confidential: false, + title: '123', + state: 'OPEN', + workItemType: { + id: '1', + name: 'Task', + iconName: 'issue-type-task', + }, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: false, + }, + ], }, ], }, @@ -138,13 +169,25 @@ export const updateWorkItemMutationResponse = { }, widgets: [ { + type: 'HIERARCHY', children: { nodes: [ { id: 'gid://gitlab/WorkItem/444', + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + confidential: false, + title: '123', + state: 'OPEN', + workItemType: { + id: '1', + name: 'Task', + iconName: 'issue-type-task', + }, }, ], }, + __typename: 'WorkItemConnection', }, { __typename: 'WorkItemWidgetAssignees', @@ -177,6 +220,12 @@ export const mockParent = { iid: '5', title: 'Parent title', confidential: false, + webUrl: 'http://gdk.test/gitlab-org/gitlab/-/issues/1', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/1', + name: 'Issue', + iconName: 'issue-type-issue', + }, }, }; @@ -193,6 +242,20 @@ export const descriptionHtmlWithCheckboxes = ` </ul> `; +const taskType = { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', +}; + +export const objectiveType = { + __typename: 'WorkItemType', + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', +}; + export const workItemResponseFactory = ({ canUpdate = false, canDelete = false, @@ -201,8 +264,10 @@ export const workItemResponseFactory = ({ datesWidgetPresent = true, labelsWidgetPresent = true, weightWidgetPresent = true, + progressWidgetPresent = true, milestoneWidgetPresent = true, iterationWidgetPresent = true, + healthStatusWidgetPresent = true, confidential = false, canInviteMembers = false, allowsScopedLabels = false, @@ -210,6 +275,7 @@ export const workItemResponseFactory = ({ lastEditedBy = null, withCheckboxes = false, parent = mockParent.parent, + workItemType = taskType, } = {}) => ({ data: { workItem: { @@ -227,12 +293,7 @@ export const workItemResponseFactory = ({ id: '1', fullPath: 'test-project-path', }, - workItemType: { - __typename: 'WorkItemType', - id: 'gid://gitlab/WorkItems::Type/5', - name: 'Task', - iconName: 'issue-type-task', - }, + workItemType, userPermissions: { deleteWorkItem: canDelete, updateWorkItem: canUpdate, @@ -298,26 +359,51 @@ export const workItemResponseFactory = ({ }, } : { type: 'MOCK TYPE' }, + progressWidgetPresent + ? { + __typename: 'WorkItemWidgetProgress', + type: 'PROGRESS', + progress: 0, + } + : { type: 'MOCK TYPE' }, milestoneWidgetPresent ? { __typename: 'WorkItemWidgetMilestone', type: 'MILESTONE', - milestone: { - expired: false, - id: 'gid://gitlab/Milestone/30', - title: 'v4.0', - }, + milestone: mockMilestone, + } + : { type: 'MOCK TYPE' }, + healthStatusWidgetPresent + ? { + __typename: 'WorkItemWidgetHealthStatus', + type: 'HEALTH_STATUS', + healthStatus: 'onTrack', } : { type: 'MOCK TYPE' }, { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', + hasChildren: true, children: { nodes: [ { id: 'gid://gitlab/WorkItem/444', createdAt: '2022-08-03T12:41:54Z', closedAt: null, + confidential: false, + title: '123', + state: 'OPEN', + workItemType: { + id: '1', + name: 'Task', + iconName: 'issue-type-task', + }, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: false, + }, + ], }, ], }, @@ -637,6 +723,8 @@ export const workItemHierarchyEmptyResponse = { id: 'gid://gitlab/WorkItem/1', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', + name: 'Issue', + iconName: 'issue-type-issue', __typename: 'WorkItemType', }, title: 'New title', @@ -660,6 +748,7 @@ export const workItemHierarchyEmptyResponse = { { type: 'HIERARCHY', parent: null, + hasChildren: false, children: { nodes: [], __typename: 'WorkItemConnection', @@ -678,6 +767,8 @@ export const workItemHierarchyNoUpdatePermissionResponse = { id: 'gid://gitlab/WorkItem/1', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', + name: 'Issue', + iconName: 'issue-type-issue', __typename: 'WorkItemType', }, title: 'New title', @@ -699,12 +790,16 @@ export const workItemHierarchyNoUpdatePermissionResponse = { { type: 'HIERARCHY', parent: null, + hasChildren: true, children: { nodes: [ { id: 'gid://gitlab/WorkItem/2', + iid: '2', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'xyz', @@ -712,6 +807,12 @@ export const workItemHierarchyNoUpdatePermissionResponse = { confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: false, + }, + ], __typename: 'WorkItem', }, ], @@ -727,8 +828,11 @@ export const workItemHierarchyNoUpdatePermissionResponse = { export const workItemTask = { id: 'gid://gitlab/WorkItem/4', + iid: '4', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'bar', @@ -741,8 +845,11 @@ export const workItemTask = { export const confidentialWorkItemTask = { id: 'gid://gitlab/WorkItem/2', + iid: '2', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'xyz', @@ -755,8 +862,11 @@ export const confidentialWorkItemTask = { export const closedWorkItemTask = { id: 'gid://gitlab/WorkItem/3', + iid: '3', workItemType: { id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', __typename: 'WorkItemType', }, title: 'abc', @@ -767,12 +877,153 @@ export const closedWorkItemTask = { __typename: 'WorkItem', }; +export const childrenWorkItems = [ + confidentialWorkItemTask, + closedWorkItemTask, + workItemTask, + { + id: 'gid://gitlab/WorkItem/5', + iid: '5', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/5', + name: 'Task', + iconName: 'issue-type-task', + __typename: 'WorkItemType', + }, + title: 'foobar', + state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + __typename: 'WorkItem', + }, +]; + export const workItemHierarchyResponse = { data: { workItem: { id: 'gid://gitlab/WorkItem/1', + iid: '1', workItemType: { id: 'gid://gitlab/WorkItems::Type/6', + name: 'Objective', + iconName: 'issue-type-objective', + __typename: 'WorkItemType', + }, + title: 'New title', + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + }, + confidential: false, + project: { + __typename: 'Project', + id: '1', + fullPath: 'test-project-path', + }, + widgets: [ + { + type: 'DESCRIPTION', + __typename: 'WorkItemWidgetDescription', + }, + { + type: 'HIERARCHY', + parent: null, + hasChildren: true, + children: { + nodes: childrenWorkItems, + __typename: 'WorkItemConnection', + }, + __typename: 'WorkItemWidgetHierarchy', + }, + ], + __typename: 'WorkItem', + }, + }, +}; + +export const workItemObjectiveWithChild = { + id: 'gid://gitlab/WorkItem/12', + iid: '12', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', + __typename: 'WorkItemType', + }, + project: { + __typename: 'Project', + id: '1', + fullPath: 'test-project-path', + }, + userPermissions: { + deleteWorkItem: true, + updateWorkItem: true, + }, + title: 'Objective', + description: 'Objective description', + state: 'OPEN', + confidential: false, + createdAt: '2022-08-03T12:41:54Z', + closedAt: null, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: true, + parent: null, + children: { + nodes: [], + }, + __typename: 'WorkItemWidgetHierarchy', + }, + { + type: 'MILESTONE', + __typename: 'WorkItemWidgetMilestone', + milestone: mockMilestone, + }, + { + type: 'ASSIGNEES', + __typename: 'WorkItemWidgetAssignees', + canInviteMembers: true, + allowsMultipleAssignees: true, + assignees: { + __typename: 'UserCoreConnection', + nodes: mockAssignees, + }, + }, + { + type: 'LABELS', + __typename: 'WorkItemWidgetLabels', + allowsScopedLabels: true, + labels: { + __typename: 'LabelConnection', + nodes: mockLabels, + }, + }, + ], + __typename: 'WorkItem', +}; + +export const workItemObjectiveNoMetadata = { + ...workItemObjectiveWithChild, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: true, + __typename: 'WorkItemWidgetHierarchy', + }, + ], +}; + +export const workItemHierarchyTreeResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/2', + iid: '2', + workItemType: { + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', __typename: 'WorkItemType', }, title: 'New title', @@ -794,22 +1045,30 @@ export const workItemHierarchyResponse = { { type: 'HIERARCHY', parent: null, + hasChildren: true, children: { nodes: [ - confidentialWorkItemTask, - closedWorkItemTask, - workItemTask, { - id: 'gid://gitlab/WorkItem/5', + id: 'gid://gitlab/WorkItem/13', + iid: '13', workItemType: { - id: 'gid://gitlab/WorkItems::Type/5', + id: 'gid://gitlab/WorkItems::Type/2411', + name: 'Objective', + iconName: 'issue-type-objective', __typename: 'WorkItemType', }, - title: 'foobar', + title: 'Objective 2', state: 'OPEN', confidential: false, createdAt: '2022-08-03T12:41:54Z', closedAt: null, + widgets: [ + { + type: 'HIERARCHY', + hasChildren: true, + __typename: 'WorkItemWidgetHierarchy', + }, + ], __typename: 'WorkItem', }, ], @@ -823,6 +1082,15 @@ export const workItemHierarchyResponse = { }, }; +export const workItemHierarchyTreeFailureResponse = { + data: {}, + errors: [ + { + message: 'Something went wrong', + }, + ], +}; + export const changeWorkItemParentMutationResponse = { data: { workItemUpdate: { @@ -856,6 +1124,7 @@ export const changeWorkItemParentMutationResponse = { __typename: 'WorkItemWidgetHierarchy', type: 'HIERARCHY', parent: null, + hasChildren: false, children: { nodes: [], }, @@ -1196,3 +1465,288 @@ export const projectWorkItemResponse = { }, }, }; + +export const mockWorkItemNotesResponse = { + data: { + workItem: { + id: 'gid://gitlab/WorkItem/600', + iid: '60', + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + }, + { + __typename: 'WorkItemWidgetWeight', + }, + { + __typename: 'WorkItemWidgetAssignees', + }, + { + __typename: 'WorkItemWidgetLabels', + }, + { + __typename: 'WorkItemWidgetDescription', + }, + { + __typename: 'WorkItemWidgetHierarchy', + }, + { + __typename: 'WorkItemWidgetStartAndDueDate', + }, + { + __typename: 'WorkItemWidgetMilestone', + }, + { + type: 'NOTES', + discussions: { + pageInfo: { + hasNextPage: false, + hasPreviousPage: false, + startCursor: null, + endCursor: null, + __typename: 'PageInfo', + }, + nodes: [ + { + id: + 'gid://gitlab/IndividualNoteDiscussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/2428', + body: 'added #31 as parent issue', + bodyHtml: + '<p data-sourcepos="1:1-1:25" dir="auto">added <a href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container=body data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue">#31</a> as parent issue</p>', + systemNoteIconName: 'link', + createdAt: '2022-11-14T04:18:59Z', + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: + 'gid://gitlab/IndividualNoteDiscussion/7b08b89a728a5ceb7de8334246837ba1d07270dc', + notes: { + nodes: [ + { + id: 'gid://gitlab/MilestoneNote/not-persisted', + body: 'changed milestone to %5', + bodyHtml: + '<p data-sourcepos="1:1-1:23" dir="auto">changed milestone to <a href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container=body data-placement="top" title="" class="gfm gfm-milestone has-tooltip">%v4.0</a></p>', + systemNoteIconName: 'clock', + createdAt: '2022-11-14T04:18:59Z', + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: + 'gid://gitlab/IndividualNoteDiscussion/0f2f195ec0d1ef95ee9d5b10446b8e96a7d83864', + notes: { + nodes: [ + { + id: 'gid://gitlab/WeightNote/not-persisted', + body: 'changed weight to 89', + bodyHtml: '<p dir="auto">changed weight to <strong>89</strong></p>', + systemNoteIconName: 'weight', + createdAt: '2022-11-25T07:16:20Z', + author: { + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80&d=identicon', + id: 'gid://gitlab/User/1', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + ], + __typename: 'DiscussionConnection', + }, + __typename: 'WorkItemWidgetNotes', + }, + ], + __typename: 'WorkItem', + }, + }, +}; +export const mockWorkItemNotesByIidResponse = { + data: { + workspace: { + id: 'gid://gitlab/Project/6', + workItems: { + nodes: [ + { + id: 'gid://gitlab/WorkItem/600', + iid: '51', + widgets: [ + { + __typename: 'WorkItemWidgetIteration', + }, + { + __typename: 'WorkItemWidgetWeight', + }, + { + __typename: 'WorkItemWidgetHealthStatus', + }, + { + __typename: 'WorkItemWidgetAssignees', + }, + { + __typename: 'WorkItemWidgetLabels', + }, + { + __typename: 'WorkItemWidgetDescription', + }, + { + __typename: 'WorkItemWidgetHierarchy', + }, + { + __typename: 'WorkItemWidgetStartAndDueDate', + }, + { + __typename: 'WorkItemWidgetMilestone', + }, + { + type: 'NOTES', + discussions: { + pageInfo: { + hasNextPage: true, + hasPreviousPage: false, + startCursor: null, + endCursor: + 'eyJjcmVhdGVkX2F0IjoiMjAyMi0xMS0xNCAwNDoxOTowMC4wOTkxMTcwMDAgKzAwMDAiLCJpZCI6IjQyNyIsIl9rZCI6Im4ifQ==', + __typename: 'PageInfo', + }, + nodes: [ + { + id: + 'gid://gitlab/IndividualNoteDiscussion/8bbc4890b6ff0f2cde93a5a0947cd2b8a13d3b6e', + notes: { + nodes: [ + { + id: 'gid://gitlab/Note/2428', + body: 'added #31 as parent issue', + bodyHtml: + '\u003cp data-sourcepos="1:1-1:25" dir="auto"\u003eadded \u003ca href="/flightjs/Flight/-/issues/31" data-reference-type="issue" data-original="#31" data-link="false" data-link-reference="false" data-project="6" data-issue="224" data-project-path="flightjs/Flight" data-iid="31" data-issue-type="issue" data-container="body" data-placement="top" title="Perferendis est quae totam quia laborum tempore ut voluptatem." class="gfm gfm-issue"\u003e#31\u003c/a\u003e as parent issue\u003c/p\u003e', + systemNoteIconName: 'link', + createdAt: '2022-11-14T04:18:59Z', + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: + 'gid://gitlab/IndividualNoteDiscussion/7b08b89a728a5ceb7de8334246837ba1d07270dc', + notes: { + nodes: [ + { + id: + 'gid://gitlab/MilestoneNote/7b08b89a728a5ceb7de8334246837ba1d07270dc', + body: 'changed milestone to %5', + bodyHtml: + '\u003cp data-sourcepos="1:1-1:23" dir="auto"\u003echanged milestone to \u003ca href="/flightjs/Flight/-/milestones/5" data-reference-type="milestone" data-original="%5" data-link="false" data-link-reference="false" data-project="6" data-milestone="30" data-container="body" data-placement="top" title="" class="gfm gfm-milestone has-tooltip"\u003e%v4.0\u003c/a\u003e\u003c/p\u003e', + systemNoteIconName: 'clock', + createdAt: '2022-11-14T04:18:59Z', + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + { + id: + 'gid://gitlab/IndividualNoteDiscussion/addbc177f7664699a135130ab05ffb78c57e4db3', + notes: { + nodes: [ + { + id: + 'gid://gitlab/IterationNote/addbc177f7664699a135130ab05ffb78c57e4db3', + body: 'changed iteration to *iteration:5352', + bodyHtml: + '\u003cp data-sourcepos="1:1-1:36" dir="auto"\u003echanged iteration to \u003ca href="/groups/flightjs/-/iterations/5352" data-reference-type="iteration" data-original="*iteration:5352" data-link="false" data-link-reference="false" data-project="6" data-iteration="5352" data-container="body" data-placement="top" title="Iteration" class="gfm gfm-iteration has-tooltip"\u003eEt autem debitis nam suscipit eos ut. Jul 13, 2022 - Jul 19, 2022\u003c/a\u003e\u003c/p\u003e', + systemNoteIconName: 'iteration', + createdAt: '2022-11-14T04:19:00Z', + author: { + id: 'gid://gitlab/User/1', + avatarUrl: + 'https://www.gravatar.com/avatar/e64c7d89f26bd1972efa854d13d7dd61?s=80\u0026d=identicon', + name: 'Administrator', + username: 'root', + webUrl: 'http://127.0.0.1:3000/root', + __typename: 'UserCore', + }, + __typename: 'Note', + }, + ], + __typename: 'NoteConnection', + }, + __typename: 'Discussion', + }, + ], + __typename: 'DiscussionConnection', + }, + __typename: 'WorkItemWidgetNotes', + }, + ], + __typename: 'WorkItem', + }, + ], + __typename: 'WorkItemConnection', + }, + __typename: 'Project', + }, + }, +}; diff --git a/spec/frontend/work_items/pages/work_item_root_spec.js b/spec/frontend/work_items/pages/work_item_root_spec.js index 880c4271024..a766962771a 100644 --- a/spec/frontend/work_items/pages/work_item_root_spec.js +++ b/spec/frontend/work_items/pages/work_item_root_spec.js @@ -55,7 +55,7 @@ describe('Work items root component', () => { isModal: false, workItemId: 'gid://gitlab/WorkItem/1', workItemParentId: null, - iid: '1', + workItemIid: '1', }); }); diff --git a/spec/frontend/work_items/router_spec.js b/spec/frontend/work_items/router_spec.js index 982f9f71f9e..b503d819435 100644 --- a/spec/frontend/work_items/router_spec.js +++ b/spec/frontend/work_items/router_spec.js @@ -1,14 +1,12 @@ import { mount } from '@vue/test-utils'; import Vue from 'vue'; import VueApollo from 'vue-apollo'; -import workItemWeightSubscription from 'ee_component/work_items/graphql/work_item_weight.subscription.graphql'; import createMockApollo from 'helpers/mock_apollo_helper'; import { workItemAssigneesSubscriptionResponse, workItemDatesSubscriptionResponse, workItemResponseFactory, workItemTitleSubscriptionResponse, - workItemWeightSubscriptionResponse, workItemLabelsSubscriptionResponse, workItemMilestoneSubscriptionResponse, workItemDescriptionSubscriptionResponse, @@ -25,6 +23,8 @@ import CreateWorkItem from '~/work_items/pages/create_work_item.vue'; import WorkItemsRoot from '~/work_items/pages/work_item_root.vue'; import { createRouter } from '~/work_items/router'; +jest.mock('~/behaviors/markdown/render_gfm'); + describe('Work items router', () => { let wrapper; @@ -33,7 +33,6 @@ describe('Work items router', () => { const workItemQueryHandler = jest.fn().mockResolvedValue(workItemResponseFactory()); const datesSubscriptionHandler = jest.fn().mockResolvedValue(workItemDatesSubscriptionResponse); const titleSubscriptionHandler = jest.fn().mockResolvedValue(workItemTitleSubscriptionResponse); - const weightSubscriptionHandler = jest.fn().mockResolvedValue(workItemWeightSubscriptionResponse); const assigneesSubscriptionHandler = jest .fn() .mockResolvedValue(workItemAssigneesSubscriptionResponse); @@ -61,10 +60,6 @@ describe('Work items router', () => { [workItemDescriptionSubscription, descriptionSubscriptionHandler], ]; - if (IS_EE) { - handlers.push([workItemWeightSubscription, weightSubscriptionHandler]); - } - wrapper = mount(App, { apolloProvider: createMockApollo(handlers), router, @@ -72,6 +67,13 @@ describe('Work items router', () => { fullPath: 'full-path', issuesListPath: 'full-path/-/issues', hasIssueWeightsFeature: false, + hasIterationsFeature: false, + hasOkrsFeature: false, + hasIssuableHealthStatusFeature: false, + }, + stubs: { + WorkItemWeight: true, + WorkItemIteration: true, }, }); }; |