diff options
author | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 21:25:58 +0300 |
---|---|---|
committer | GitLab Bot <gitlab-bot@gitlab.com> | 2021-06-16 21:25:58 +0300 |
commit | a5f4bba440d7f9ea47046a0a561d49adf0a1e6d4 (patch) | |
tree | fb69158581673816a8cd895f9d352dcb3c678b1e /spec/frontend/vue_shared | |
parent | d16b2e8639e99961de6ddc93909f3bb5c1445ba1 (diff) |
Add latest changes from gitlab-org/gitlab@14-0-stable-eev14.0.0-rc42
Diffstat (limited to 'spec/frontend/vue_shared')
60 files changed, 1081 insertions, 2411 deletions
diff --git a/spec/frontend/vue_shared/alert_details/alert_status_spec.js b/spec/frontend/vue_shared/alert_details/alert_status_spec.js index c532f688cbd..3fc13243bce 100644 --- a/spec/frontend/vue_shared/alert_details/alert_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/alert_status_spec.js @@ -1,5 +1,5 @@ import { GlDropdown, GlDropdownItem } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import waitForPromises from 'helpers/wait_for_promises'; import updateAlertStatusMutation from '~/graphql_shared//mutations/alert_status_update.mutation.graphql'; import Tracking from '~/tracking'; @@ -10,9 +10,10 @@ const mockAlert = mockAlerts[0]; describe('AlertManagementStatus', () => { let wrapper; - const findStatusDropdown = () => wrapper.find(GlDropdown); - const findFirstStatusOption = () => findStatusDropdown().find(GlDropdownItem); + const findStatusDropdown = () => wrapper.findComponent(GlDropdown); + const findFirstStatusOption = () => findStatusDropdown().findComponent(GlDropdownItem); const findAllStatusOptions = () => findStatusDropdown().findAll(GlDropdownItem); + const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const selectFirstStatusOption = () => { findFirstStatusOption().vm.$emit('click'); @@ -21,7 +22,7 @@ describe('AlertManagementStatus', () => { }; function mountComponent({ props = {}, provide = {}, loading = false, stubs = {} } = {}) { - wrapper = shallowMount(AlertManagementStatus, { + wrapper = shallowMountExtended(AlertManagementStatus, { propsData: { alert: { ...mockAlert }, projectPath: 'gitlab-org/gitlab', @@ -43,17 +44,29 @@ describe('AlertManagementStatus', () => { }); } - beforeEach(() => { - mountComponent(); - }); - afterEach(() => { if (wrapper) { wrapper.destroy(); - wrapper = null; } }); + describe('sidebar', () => { + it('displays the dropdown status header', () => { + mountComponent({ props: { isSidebar: true } }); + expect(findStatusDropdownHeader().exists()).toBe(true); + }); + + it('hides the dropdown by default', () => { + mountComponent({ props: { isSidebar: true } }); + expect(wrapper.classes()).toContain('gl-display-none'); + }); + + it('shows the dropdown', () => { + mountComponent({ props: { isSidebar: true, isDropdownShowing: true } }); + expect(wrapper.classes()).toContain('show'); + }); + }); + describe('updating the alert status', () => { const iid = '1527542'; const mockUpdatedMutationResult = { @@ -99,6 +112,13 @@ describe('AlertManagementStatus', () => { ]); }); + it('emits an update event at the start and ending of the updating', async () => { + await selectFirstStatusOption(); + expect(wrapper.emitted('handle-updating').length > 1).toBe(true); + expect(wrapper.emitted('handle-updating')[0]).toEqual([true]); + expect(wrapper.emitted('handle-updating')[1]).toEqual([false]); + }); + it('emits an error when triggered a second time', async () => { await selectFirstStatusOption(); await wrapper.vm.$nextTick(); diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js index db9b0930c06..9ae45071f45 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_assignees_spec.js @@ -21,6 +21,7 @@ describe('Alert Details Sidebar Assignees', () => { id: 1, name: 'User 1', username: 'root', + webUrl: 'https://gitlab:3443/root', }, { avatar_url: @@ -28,6 +29,7 @@ describe('Alert Details Sidebar Assignees', () => { id: 2, name: 'User 2', username: 'not-root', + webUrl: 'https://gitlab:3443/non-root', }, ]; @@ -128,7 +130,7 @@ describe('Alert Details Sidebar Assignees', () => { variables: { iid: '1527542', assigneeUsernames: ['root'], - projectPath: 'projectPath', + fullPath: 'projectPath', }, }); }); @@ -137,7 +139,7 @@ describe('Alert Details Sidebar Assignees', () => { wrapper.setData({ isDropdownSearching: false }); const errorMutationResult = { data: { - alertSetAssignees: { + issuableSetAssignees: { errors: ['There was a problem for sure.'], alert: {}, }, diff --git a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js index d5be5b623b8..b00a20dab1a 100644 --- a/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js +++ b/spec/frontend/vue_shared/alert_details/sidebar/alert_sidebar_status_spec.js @@ -1,6 +1,5 @@ -import { GlDropdown, GlDropdownItem, GlLoadingIcon } from '@gitlab/ui'; -import { mountExtended } from 'helpers/vue_test_utils_helper'; -import updateAlertStatusMutation from '~/graphql_shared/mutations/alert_status_update.mutation.graphql'; +import { GlDropdown, GlLoadingIcon } from '@gitlab/ui'; +import { shallowMountExtended } from 'helpers/vue_test_utils_helper'; import AlertStatus from '~/vue_shared/alert_details/components/alert_status.vue'; import AlertSidebarStatus from '~/vue_shared/alert_details/components/sidebar/sidebar_status.vue'; import { PAGE_CONFIG } from '~/vue_shared/alert_details/constants'; @@ -11,9 +10,7 @@ const mockAlert = mockAlerts[0]; describe('Alert Details Sidebar Status', () => { let wrapper; const findStatusDropdown = () => wrapper.findComponent(GlDropdown); - const findStatusDropdownItem = () => wrapper.findComponent(GlDropdownItem); const findStatusLoadingIcon = () => wrapper.findComponent(GlLoadingIcon); - const findStatusDropdownHeader = () => wrapper.findByTestId('dropdown-header'); const findAlertStatus = () => wrapper.findComponent(AlertStatus); const findStatus = () => wrapper.findByTestId('status'); const findSidebarIcon = () => wrapper.findByTestId('status-icon'); @@ -25,7 +22,7 @@ describe('Alert Details Sidebar Status', () => { stubs = {}, provide = {}, } = {}) { - wrapper = mountExtended(AlertSidebarStatus, { + wrapper = shallowMountExtended(AlertSidebarStatus, { propsData: { alert: { ...mockAlert }, ...data, @@ -63,11 +60,7 @@ describe('Alert Details Sidebar Status', () => { }); it('displays status dropdown', () => { - expect(findStatusDropdown().exists()).toBe(true); - }); - - it('displays the dropdown status header', () => { - expect(findStatusDropdownHeader().exists()).toBe(true); + expect(findAlertStatus().exists()).toBe(true); }); it('does not display the collapsed sidebar icon', () => { @@ -75,42 +68,24 @@ describe('Alert Details Sidebar Status', () => { }); describe('updating the alert status', () => { - const mockUpdatedMutationResult = { - data: { - updateAlertStatus: { - errors: [], - alert: { - status: 'acknowledged', - }, - }, - }, - }; - - beforeEach(() => { + it('ensures dropdown is hidden when loading', async () => { mountComponent({ data: { alert: mockAlert }, sidebarCollapsed: false, loading: false, }); - }); - - it('calls `$apollo.mutate` with `updateAlertStatus` mutation and variables containing `iid`, `status`, & `projectPath`', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockResolvedValue(mockUpdatedMutationResult); - findStatusDropdownItem().vm.$emit('click'); - - expect(wrapper.vm.$apollo.mutate).toHaveBeenCalledWith({ - mutation: updateAlertStatusMutation, - variables: { - iid: '1527542', - status: 'TRIGGERED', - projectPath: 'projectPath', - }, - }); + findAlertStatus().vm.$emit('handle-updating', true); + await wrapper.vm.$nextTick(); + expect(findStatusLoadingIcon().exists()).toBe(true); }); it('stops updating when the request fails', () => { - jest.spyOn(wrapper.vm.$apollo, 'mutate').mockReturnValue(Promise.reject(new Error())); - findStatusDropdownItem().vm.$emit('click'); + mountComponent({ + data: { alert: mockAlert }, + sidebarCollapsed: false, + loading: false, + }); + findAlertStatus().vm.$emit('handle-updating', false); expect(findStatusLoadingIcon().exists()).toBe(false); expect(findStatus().text()).toBe('Triggered'); }); diff --git a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap index 3be609f0dad..3f91591f5cd 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/awards_list_spec.js.snap @@ -5,7 +5,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` class="awards js-awards-block" > <button - class="btn gl-mr-3 btn-default btn-md gl-button" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" title="Ada, Leonardo, and Marie" type="button" @@ -35,7 +35,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You, Ada, and Marie" type="button" @@ -65,7 +65,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" title="Ada and Jane" type="button" @@ -95,7 +95,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + 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" type="button" @@ -125,7 +125,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" @@ -155,7 +155,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button" data-testid="award-button" title="Marie" type="button" @@ -185,7 +185,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </span> </button> <button - class="btn gl-mr-3 btn-default btn-md gl-button selected" + class="btn gl-mr-3 gl-my-2 btn-default btn-md gl-button selected" data-testid="award-button" title="You" type="button" @@ -216,7 +216,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` </button> <div - class="award-menu-holder" + class="award-menu-holder gl-my-2" > <button aria-label="Add reaction" @@ -238,6 +238,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="slight-smile-icon" + role="img" > <use href="#slight-smile" @@ -252,6 +253,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="smiley-icon" + role="img" > <use href="#smiley" @@ -266,6 +268,7 @@ exports[`vue_shared/components/awards_list default matches snapshot 1`] = ` aria-hidden="true" class="gl-icon s16" data-testid="smile-icon" + role="img" > <use href="#smile" diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index adb6c935f96..45d34bcdd3f 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -14,6 +14,7 @@ exports[`Expand button on click when short text is provided renders button after aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" @@ -43,6 +44,7 @@ exports[`Expand button on click when short text is provided renders button after aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" @@ -67,6 +69,7 @@ exports[`Expand button when short text is provided renders button before text 1` aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" @@ -96,6 +99,7 @@ exports[`Expand button when short text is provided renders button before text 1` aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="ellipsis_h-icon" + role="img" > <use href="#ellipsis_h" diff --git a/spec/frontend/vue_shared/components/alert_details_table_spec.js b/spec/frontend/vue_shared/components/alert_details_table_spec.js index 03b04a92bdf..b9a8a5bee97 100644 --- a/spec/frontend/vue_shared/components/alert_details_table_spec.js +++ b/spec/frontend/vue_shared/components/alert_details_table_spec.js @@ -1,4 +1,4 @@ -import { GlLoadingIcon, GlTable } from '@gitlab/ui'; +import { GlLink, GlLoadingIcon, GlTable } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; @@ -7,6 +7,9 @@ const mockAlert = { title: 'SyntaxError: Invalid or unexpected token', severity: 'CRITICAL', eventCount: 7, + service: 'https://gitlab.com', + // eslint-disable-next-line no-script-url + description: 'javascript:alert("XSS")', createdAt: '2020-04-17T23:18:14.996Z', startedAt: '2020-04-17T23:18:14.996Z', endedAt: '2020-04-17T23:18:14.996Z', @@ -43,7 +46,7 @@ describe('AlertDetails', () => { wrapper = null; }); - const findTableComponent = () => wrapper.find(GlTable); + const findTableComponent = () => wrapper.findComponent(GlTable); const findTableKeys = () => findTableComponent().findAll('tbody td:first-child'); const findTableFieldValueByKey = (fieldKey) => findTableComponent() @@ -52,6 +55,7 @@ describe('AlertDetails', () => { .at(0) .find('td:nth-child(2)'); const findTableField = (fields, fieldName) => fields.filter((row) => row.text() === fieldName); + const findTableLinks = () => wrapper.findAllComponents(GlLink); describe('Alert details', () => { describe('empty state', () => { @@ -88,7 +92,16 @@ describe('AlertDetails', () => { it('should show allowed alert fields', () => { const fields = findTableKeys(); - ['Iid', 'Title', 'Severity', 'Status', 'Hosts', 'Environment'].forEach((field) => { + [ + 'Iid', + 'Title', + 'Severity', + 'Status', + 'Hosts', + 'Environment', + 'Service', + 'Description', + ].forEach((field) => { expect(findTableField(fields, field).exists()).toBe(true); }); }); @@ -99,6 +112,12 @@ describe('AlertDetails', () => { expect(findTableField(fields, field).exists()).toBe(false); }); }); + + it('should render a clickable URL if safe', () => { + expect(findTableLinks().wrappers).toHaveLength(1); + expect(findTableLinks().at(0).props('isUnsafeLink')).toBe(false); + expect(findTableLinks().at(0).attributes('href')).toBe(mockAlert.service); + }); }); describe('environment', () => { diff --git a/spec/frontend/vue_shared/components/awards_list_spec.js b/spec/frontend/vue_shared/components/awards_list_spec.js index 550ac4a9d38..55f9eedc169 100644 --- a/spec/frontend/vue_shared/components/awards_list_spec.js +++ b/spec/frontend/vue_shared/components/awards_list_spec.js @@ -41,7 +41,14 @@ const TEST_AWARDS = [ ]; const TEST_ADD_BUTTON_CLASS = 'js-test-add-button-class'; -const REACTION_CONTROL_CLASSES = ['btn', 'gl-mr-3', 'btn-default', 'btn-md', 'gl-button']; +const REACTION_CONTROL_CLASSES = [ + 'btn', + 'gl-mr-3', + 'gl-my-2', + 'btn-default', + 'btn-md', + 'gl-button', +]; describe('vue_shared/components/awards_list', () => { let wrapper; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index b2ed79cd75a..93cddff8421 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -1,6 +1,9 @@ import { useLocalStorageSpy } from 'helpers/local_storage_helper'; import AccessorUtilities from '~/lib/utils/accessor'; + +import { FILTERED_SEARCH_TERM } from '~/vue_shared/components/filtered_search_bar/constants'; + import { stripQuotes, uniqueTokens, @@ -210,6 +213,19 @@ describe('filterToQueryObject', () => { const res = filterToQueryObject({ [token]: value }); expect(res).toEqual(result); }); + + it.each([ + [FILTERED_SEARCH_TERM, [{ value: '' }], { search: '' }], + [FILTERED_SEARCH_TERM, [{ value: 'bar' }], { search: 'bar' }], + [FILTERED_SEARCH_TERM, [{ value: 'bar' }, { value: '' }], { search: 'bar' }], + [FILTERED_SEARCH_TERM, [{ value: 'bar' }, { value: 'baz' }], { search: 'bar baz' }], + ])( + 'when filteredSearchTermKey=search gathers filter values %s=%j into query object=%j', + (token, value, result) => { + const res = filterToQueryObject({ [token]: value }, { filteredSearchTermKey: 'search' }); + expect(res).toEqual(result); + }, + ); }); describe('urlQueryToFilter', () => { @@ -255,10 +271,61 @@ describe('urlQueryToFilter', () => { }, ], ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }], - ])('gathers filter values %s into query object=%j', (query, result) => { - const res = urlQueryToFilter(query); - expect(res).toEqual(result); - }); + ['nop=1¬[nop]=2', {}, { filterNamesAllowList: ['foo'] }], + [ + 'foo[]=bar¬[foo][]=baz&nop=xxx¬[nop]=yyy', + { + foo: [ + { value: 'bar', operator: '=' }, + { value: 'baz', operator: '!=' }, + ], + }, + { filterNamesAllowList: ['foo'] }, + ], + [ + 'search=term&foo=bar', + { + [FILTERED_SEARCH_TERM]: [{ value: 'term' }], + foo: { value: 'bar', operator: '=' }, + }, + { filteredSearchTermKey: 'search' }, + ], + [ + 'search=my terms', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + }, + { filteredSearchTermKey: 'search' }, + ], + [ + 'search[]=my&search[]=terms', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + }, + { filteredSearchTermKey: 'search' }, + ], + [ + 'search=my+terms', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + }, + { filteredSearchTermKey: 'search', legacySpacesDecode: false }, + ], + [ + 'search=my terms&foo=bar&nop=xxx', + { + [FILTERED_SEARCH_TERM]: [{ value: 'my' }, { value: 'terms' }], + foo: { value: 'bar', operator: '=' }, + }, + { filteredSearchTermKey: 'search', filterNamesAllowList: ['foo'] }, + ], + ])( + 'gathers filter values %s into query object=%j when options %j', + (query, result, options = undefined) => { + const res = urlQueryToFilter(query, options); + expect(res).toEqual(result); + }, + ); }); describe('getRecentlyUsedTokenValues', () => { 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 23e4deab9c1..134c6c8b929 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 @@ -65,8 +65,8 @@ export const mockMilestones = [ ]; export const mockEpics = [ - { iid: 1, id: 1, title: 'Foo' }, - { iid: 2, id: 2, title: 'Bar' }, + { iid: 1, id: 1, title: 'Foo', group_full_path: 'gitlab-org' }, + { iid: 2, id: 2, title: 'Bar', group_full_path: 'gitlab-org/design' }, ]; export const mockEmoji1 = { diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js index 05bad572472..4140ec09b4e 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/store/modules/filters/actions_spec.js @@ -3,7 +3,7 @@ import MockAdapter from 'axios-mock-adapter'; import testAction from 'helpers/vuex_action_helper'; import { mockBranches } from 'jest/vue_shared/components/filtered_search_bar/mock_data'; import Api from '~/api'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import httpStatusCodes from '~/lib/utils/http_status'; import * as actions from '~/vue_shared/components/filtered_search_bar/store/modules/filters/actions'; import * as types from '~/vue_shared/components/filtered_search_bar/store/modules/filters/mutation_types'; diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 3b50927dcc6..f50eafdbc52 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -1,13 +1,13 @@ import { - GlFilteredSearchToken, GlFilteredSearchTokenSegment, GlFilteredSearchSuggestion, GlDropdownDivider, + GlAvatar, } from '@gitlab/ui'; import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -15,6 +15,7 @@ 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 BaseToken from '~/vue_shared/components/filtered_search_bar/tokens/base_token.vue'; import { mockAuthorToken, mockAuthors } from '../mock_data'; @@ -29,12 +30,22 @@ const defaultStubs = { }, }; +const mockPreloadedAuthors = [ + { + id: 13, + name: 'Administrator', + username: 'root', + avatar_url: 'avatar/url', + }, +]; + function createComponent(options = {}) { const { config = mockAuthorToken, value = { data: '' }, active = false, stubs = defaultStubs, + data = {}, } = options; return mount(AuthorToken, { propsData: { @@ -47,132 +58,172 @@ function createComponent(options = {}) { alignSuggestions: function fakeAlignSuggestions() {}, suggestionsListClass: 'custom-class', }, + data() { + return { ...data }; + }, stubs, }); } describe('AuthorToken', () => { + const originalGon = window.gon; + const currentUserLength = 1; let mock; let wrapper; + const getBaseToken = () => wrapper.findComponent(BaseToken); + beforeEach(() => { mock = new MockAdapter(axios); - wrapper = createComponent(); }); afterEach(() => { + window.gon = originalGon; mock.restore(); wrapper.destroy(); }); - describe('computed', () => { - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - wrapper = createComponent({ value: { data: 'FOO' } }); - - expect(wrapper.vm.currentValue).toBe('foo'); + describe('methods', () => { + describe('fetchAuthorBySearchTerm', () => { + beforeEach(() => { + wrapper = createComponent(); }); - }); - describe('activeAuthor', () => { - it('returns object for currently present `value.data`', async () => { - wrapper = createComponent({ value: { data: mockAuthors[0].username } }); - - wrapper.setData({ - authors: mockAuthors, - }); + it('calls `config.fetchAuthors` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors'); - await wrapper.vm.$nextTick(); + getBaseToken().vm.$emit('fetch-token-values', mockAuthors[0].username); - expect(wrapper.vm.activeAuthor).toEqual(mockAuthors[0]); + expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( + mockAuthorToken.fetchPath, + mockAuthors[0].username, + ); }); - }); - }); - - describe('fetchAuthorBySearchTerm', () => { - it('calls `config.fetchAuthors` with provided searchTerm param', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors'); - - wrapper.vm.fetchAuthorBySearchTerm(mockAuthors[0].username); - expect(wrapper.vm.config.fetchAuthors).toHaveBeenCalledWith( - mockAuthorToken.fetchPath, - mockAuthors[0].username, - ); - }); - - it('sets response to `authors` when request is succesful', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); + it('sets response to `authors` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockResolvedValue(mockAuthors); - wrapper.vm.fetchAuthorBySearchTerm('root'); + getBaseToken().vm.$emit('fetch-token-values', 'root'); - return waitForPromises().then(() => { - expect(wrapper.vm.authors).toEqual(mockAuthors); + return waitForPromises().then(() => { + expect(getBaseToken().props('tokenValues')).toEqual(mockAuthors); + }); }); - }); - it('calls `createFlash` with flash error message when request fails', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); - wrapper.vm.fetchAuthorBySearchTerm('root'); + getBaseToken().vm.$emit('fetch-token-values', 'root'); - return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching users.'); + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching users.', + }); + }); }); - }); - it('sets `loading` to false when request completes', () => { - jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); + it('sets `loading` to false when request completes', async () => { + jest.spyOn(wrapper.vm.config, 'fetchAuthors').mockRejectedValue({}); - wrapper.vm.fetchAuthorBySearchTerm('root'); + getBaseToken().vm.$emit('fetch-token-values', 'root'); - return waitForPromises().then(() => { - expect(wrapper.vm.loading).toBe(false); + await waitForPromises(); + + expect(getBaseToken().props('tokensListLoading')).toBe(false); }); }); }); describe('template', () => { - beforeEach(() => { - wrapper.setData({ - authors: mockAuthors, + const activateTokenValuesList = async () => { + const tokenSegments = wrapper.findAllComponents(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + }; + + it('renders base-token component', () => { + wrapper = createComponent({ + value: { data: mockAuthors[0].username }, + data: { authors: mockAuthors }, }); - return wrapper.vm.$nextTick(); - }); + const baseTokenEl = getBaseToken(); - it('renders gl-filtered-search-token component', () => { - expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + tokenValues: mockAuthors, + fnActiveTokenValue: wrapper.vm.getActiveAuthor, + }); }); it('renders token item when value is selected', () => { - wrapper.setProps({ + wrapper = createComponent({ value: { data: mockAuthors[0].username }, + data: { authors: mockAuthors }, + stubs: { Portal: true }, }); return wrapper.vm.$nextTick(() => { const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); expect(tokenSegments).toHaveLength(3); // Author, =, "Administrator" - expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator" + + const tokenValue = tokenSegments.at(2); + + expect(tokenValue.findComponent(GlAvatar).props('src')).toBe(mockAuthors[0].avatar_url); + expect(tokenValue.text()).toBe(mockAuthors[0].name); // "Administrator" }); }); + it('renders token value with correct avatarUrl from author object', async () => { + const getAvatarEl = () => + wrapper.findAll(GlFilteredSearchTokenSegment).at(2).findComponent(GlAvatar); + + wrapper = createComponent({ + value: { data: mockAuthors[0].username }, + data: { + authors: [ + { + ...mockAuthors[0], + }, + ], + }, + stubs: { Portal: true }, + }); + + await wrapper.vm.$nextTick(); + + expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + + wrapper.setData({ + authors: [ + { + ...mockAuthors[0], + avatarUrl: mockAuthors[0].avatar_url, + avatar_url: undefined, + }, + ], + }); + + await wrapper.vm.$nextTick(); + + expect(getAvatarEl().props('src')).toBe(mockAuthors[0].avatar_url); + }); + it('renders provided defaultAuthors as suggestions', async () => { const defaultAuthors = DEFAULT_NONE_ANY; wrapper = createComponent({ active: true, - config: { ...mockAuthorToken, defaultAuthors }, + config: { ...mockAuthorToken, defaultAuthors, preloadedAuthors: mockPreloadedAuthors }, stubs: { Portal: true }, }); - const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); - const suggestionsSegment = tokenSegments.at(2); - suggestionsSegment.vm.$emit('activate'); - await wrapper.vm.$nextTick(); + + await activateTokenValuesList(); const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(defaultAuthors.length); + expect(suggestions).toHaveLength(defaultAuthors.length + currentUserLength); defaultAuthors.forEach((label, index) => { expect(suggestions.at(index).text()).toBe(label.text); }); @@ -189,25 +240,42 @@ describe('AuthorToken', () => { suggestionsSegment.vm.$emit('activate'); await wrapper.vm.$nextTick(); - expect(wrapper.find(GlFilteredSearchSuggestion).exists()).toBe(false); expect(wrapper.find(GlDropdownDivider).exists()).toBe(false); }); it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => { wrapper = createComponent({ active: true, - config: { ...mockAuthorToken }, + config: { ...mockAuthorToken, preloadedAuthors: mockPreloadedAuthors }, stubs: { Portal: true }, }); - const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); - const suggestionsSegment = tokenSegments.at(2); - suggestionsSegment.vm.$emit('activate'); - await wrapper.vm.$nextTick(); + + await activateTokenValuesList(); const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); - expect(suggestions).toHaveLength(1); + expect(suggestions).toHaveLength(1 + currentUserLength); expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text); }); + + describe('when loading', () => { + beforeEach(() => { + wrapper = createComponent({ + active: true, + config: { + ...mockAuthorToken, + preloadedAuthors: mockPreloadedAuthors, + defaultAuthors: [], + }, + stubs: { Portal: true }, + }); + }); + + it('shows current user', () => { + const firstSuggestion = wrapper.findComponent(GlFilteredSearchSuggestion).text(); + expect(firstSuggestion).toContain('Administrator'); + expect(firstSuggestion).toContain('@root'); + }); + }); }); }); 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 0db47f1f189..602864f4fa5 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 @@ -175,6 +175,23 @@ describe('BaseToken', () => { expect(setTokenValueToRecentlyUsed).toHaveBeenCalledWith(mockStorageKey, mockTokenValue); }); + + it('does not add token from preloadedTokenValues', async () => { + const mockTokenValue = { + id: 1, + title: 'Foo', + }; + + wrapper.setProps({ + preloadedTokenValues: [mockTokenValue], + }); + + await wrapper.vm.$nextTick(); + + wrapper.vm.handleTokenValueSelected(mockTokenValue); + + expect(setTokenValueToRecentlyUsed).not.toHaveBeenCalled(); + }); }); }); 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 fb48aea8e4f..778a214f97e 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 @@ -7,7 +7,7 @@ import { import { mount } from '@vue/test-utils'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { @@ -121,7 +121,9 @@ describe('EmojiToken', () => { wrapper.vm.fetchEmojiBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching emojis.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching emojis.', + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js index addc058f658..68ed46fc3a2 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/epic_token_spec.js @@ -67,18 +67,6 @@ describe('EpicToken', () => { await wrapper.vm.$nextTick(); }); - - describe('activeEpic', () => { - it('returns object for currently present `value.data`', async () => { - wrapper.setProps({ - value: { data: `${mockEpics[0].iid}` }, - }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.activeEpic).toEqual(mockEpics[0]); - }); - }); }); describe('methods', () => { @@ -86,9 +74,12 @@ describe('EpicToken', () => { it('calls `config.fetchEpics` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchEpics'); - wrapper.vm.fetchEpicsBySearchTerm('foo'); + wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); - expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith('foo'); + expect(wrapper.vm.config.fetchEpics).toHaveBeenCalledWith({ + epicPath: '', + search: 'foo', + }); }); it('sets response to `epics` when request is successful', async () => { @@ -96,7 +87,7 @@ describe('EpicToken', () => { data: mockEpics, }); - wrapper.vm.fetchEpicsBySearchTerm(); + wrapper.vm.fetchEpicsBySearchTerm({}); await waitForPromises(); @@ -106,7 +97,7 @@ describe('EpicToken', () => { it('calls `createFlash` with flash error message when request fails', async () => { jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm('foo'); + wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); await waitForPromises(); @@ -118,7 +109,7 @@ describe('EpicToken', () => { it('sets `loading` to false when request completes', async () => { jest.spyOn(wrapper.vm.config, 'fetchEpics').mockRejectedValue({}); - wrapper.vm.fetchEpicsBySearchTerm('foo'); + wrapper.vm.fetchEpicsBySearchTerm({ search: 'foo' }); await waitForPromises(); @@ -128,9 +119,11 @@ describe('EpicToken', () => { }); describe('template', () => { + const getTokenValueEl = () => wrapper.findAllComponents(GlFilteredSearchTokenSegment).at(2); + beforeEach(async () => { wrapper = createComponent({ - value: { data: `${mockEpics[0].iid}` }, + value: { data: `${mockEpics[0].group_full_path}::&${mockEpics[0].iid}` }, data: { epics: mockEpics }, }); @@ -147,5 +140,19 @@ describe('EpicToken', () => { expect(tokenSegments).toHaveLength(3); expect(tokenSegments.at(2).text()).toBe(`${mockEpics[0].title}::&${mockEpics[0].iid}`); }); + + it.each` + value | valueType | tokenValueString + ${`${mockEpics[0].group_full_path}::&${mockEpics[0].iid}`} | ${'string'} | ${`${mockEpics[0].title}::&${mockEpics[0].iid}`} + ${`${mockEpics[1].group_full_path}::&${mockEpics[1].iid}`} | ${'number'} | ${`${mockEpics[1].title}::&${mockEpics[1].iid}`} + `('renders token item when selection is a $valueType', async ({ value, tokenValueString }) => { + wrapper.setProps({ + value: { data: value }, + }); + + await wrapper.vm.$nextTick(); + + expect(getTokenValueEl().text()).toBe(tokenValueString); + }); }); }); 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 57514a0c499..dd1c61b92b8 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -1,5 +1,4 @@ import { - GlFilteredSearchToken, GlFilteredSearchSuggestion, GlFilteredSearchTokenSegment, GlDropdownDivider, @@ -11,13 +10,14 @@ import { mockRegularLabel, mockLabels, } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; -import { deprecatedCreateFlash as createFlash } from '~/flash'; +import createFlash from '~/flash'; import axios from '~/lib/utils/axios_utils'; import { DEFAULT_LABELS, DEFAULT_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'; import { mockLabelToken } from '../mock_data'; @@ -25,6 +25,7 @@ import { mockLabelToken } from '../mock_data'; jest.mock('~/flash'); const defaultStubs = { Portal: true, + BaseToken, GlFilteredSearchSuggestionList: { template: '<div></div>', methods: { @@ -68,55 +69,17 @@ describe('LabelToken', () => { wrapper.destroy(); }); - describe('computed', () => { - beforeEach(async () => { - // Label title with spaces is always enclosed in quotations by component. - wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); - - wrapper.setData({ - labels: mockLabels, - }); - - await wrapper.vm.$nextTick(); - }); - - describe('currentValue', () => { - it('returns lowercase string for `value.data`', () => { - expect(wrapper.vm.currentValue).toBe('"foo label"'); - }); - }); - - describe('activeLabel', () => { - it('returns object for currently present `value.data`', () => { - expect(wrapper.vm.activeLabel).toEqual(mockRegularLabel); - }); - }); - - describe('containerStyle', () => { - it('returns object containing `backgroundColor` and `color` properties based on `activeLabel` value', () => { - expect(wrapper.vm.containerStyle).toEqual({ - backgroundColor: mockRegularLabel.color, - color: mockRegularLabel.textColor, - }); - }); - - it('returns empty object when `activeLabel` is not set', async () => { - wrapper.setData({ - labels: [], - }); - - await wrapper.vm.$nextTick(); - - expect(wrapper.vm.containerStyle).toEqual({}); - }); - }); - }); - describe('methods', () => { beforeEach(() => { wrapper = createComponent(); }); + describe('getActiveLabel', () => { + it('returns label object from labels array based on provided `currentValue` param', () => { + expect(wrapper.vm.getActiveLabel(mockLabels, 'foo label')).toEqual(mockRegularLabel); + }); + }); + describe('getLabelName', () => { it('returns value of `name` or `title` property present in provided label param', () => { let mockLabel = { @@ -158,7 +121,9 @@ describe('LabelToken', () => { wrapper.vm.fetchLabelBySearchTerm('foo'); return waitForPromises().then(() => { - expect(createFlash).toHaveBeenCalledWith('There was a problem fetching labels.'); + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching labels.', + }); }); }); @@ -187,8 +152,14 @@ describe('LabelToken', () => { await wrapper.vm.$nextTick(); }); - it('renders gl-filtered-search-token component', () => { - expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + it('renders base-token component', () => { + const baseTokenEl = wrapper.find(BaseToken); + + expect(baseTokenEl.exists()).toBe(true); + expect(baseTokenEl.props()).toMatchObject({ + tokenValues: mockLabels, + fnActiveTokenValue: wrapper.vm.getActiveLabel, + }); }); it('renders token item when value is selected', () => { diff --git a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap index e5035614196..ff1dad2de68 100644 --- a/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap +++ b/spec/frontend/vue_shared/components/form/__snapshots__/title_spec.js.snap @@ -4,6 +4,7 @@ exports[`Title edit field matches the snapshot 1`] = ` <gl-form-group-stub label="Title" label-for="title-field-edit" + labeldescription="" > <gl-form-input-stub /> </gl-form-group-stub> diff --git a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js index 5c29c267c99..2658fa4a706 100644 --- a/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_assignees_spec.js @@ -91,7 +91,7 @@ describe('IssueAssigneesComponent', () => { }); it('computes alt text for assignee avatar', () => { - expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Avatar for Terrell Graham'); + expect(vm.avatarUrlTitle(mockAssigneesList[0])).toBe('Assigned to Terrell Graham'); }); it('renders component root element with class `issue-assignees`', () => { @@ -106,7 +106,7 @@ describe('IssueAssigneesComponent', () => { const expected = mockAssigneesList.slice(0, TEST_MAX_VISIBLE - 1).map((x) => expect.objectContaining({ linkHref: x.web_url, - imgAlt: `Avatar for ${x.name}`, + imgAlt: `Assigned to ${x.name}`, imgCssClasses: TEST_CSS_CLASSES, imgSrc: x.avatar_url, imgSize: TEST_ICON_SIZE, diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap index 30b7f0c2d28..23cf6ef9785 100644 --- a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -59,6 +59,7 @@ exports[`Package code instruction single line to match the default snapshot 1`] aria-hidden="true" class="gl-button-icon gl-icon s16" data-testid="copy-to-clipboard-icon" + role="img" > <use href="#copy-to-clipboard" diff --git a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js deleted file mode 100644 index ce2b0d1ddc1..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/editor_service_spec.js +++ /dev/null @@ -1,214 +0,0 @@ -import buildCustomRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; -import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; -import { - generateToolbarItem, - addCustomEventListener, - removeCustomEventListener, - registerHTMLToMarkdownRenderer, - addImage, - insertVideo, - getMarkdown, - getEditorOptions, -} from '~/vue_shared/components/rich_content_editor/services/editor_service'; -import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; - -jest.mock('~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'); -jest.mock('~/vue_shared/components/rich_content_editor/services/build_custom_renderer'); -jest.mock('~/vue_shared/components/rich_content_editor/services/sanitize_html'); - -describe('Editor Service', () => { - let mockInstance; - let event; - let handler; - const parseHtml = (str) => { - const wrapper = document.createElement('div'); - wrapper.innerHTML = str; - return wrapper.firstChild; - }; - - beforeEach(() => { - mockInstance = { - eventManager: { addEventType: jest.fn(), removeEventHandler: jest.fn(), listen: jest.fn() }, - editor: { - exec: jest.fn(), - isWysiwygMode: jest.fn(), - getSquire: jest.fn(), - insertText: jest.fn(), - }, - invoke: jest.fn(), - toMarkOptions: { - renderer: { - constructor: { - factory: jest.fn(), - }, - }, - }, - }; - event = 'someCustomEvent'; - handler = jest.fn(); - }); - - describe('generateToolbarItem', () => { - const config = { - icon: 'bold', - command: 'some-command', - tooltip: 'Some Tooltip', - event: 'some-event', - }; - - const generatedItem = generateToolbarItem(config); - - it('generates the correct command', () => { - expect(generatedItem.options.command).toBe(config.command); - }); - - it('generates the correct event', () => { - expect(generatedItem.options.event).toBe(config.event); - }); - - it('generates a divider when isDivider is set to true', () => { - const isDivider = true; - - expect(generateToolbarItem({ isDivider })).toBe('divider'); - }); - }); - - describe('addCustomEventListener', () => { - it('registers an event type on the instance and adds an event handler', () => { - addCustomEventListener(mockInstance, event, handler); - - expect(mockInstance.eventManager.addEventType).toHaveBeenCalledWith(event); - expect(mockInstance.eventManager.listen).toHaveBeenCalledWith(event, handler); - }); - }); - - describe('removeCustomEventListener', () => { - it('removes an event handler from the instance', () => { - removeCustomEventListener(mockInstance, event, handler); - - expect(mockInstance.eventManager.removeEventHandler).toHaveBeenCalledWith(event, handler); - }); - }); - - describe('addImage', () => { - const file = new File([], 'some-file.jpg'); - const mockImage = { imageUrl: 'some/url.png', altText: 'some alt text' }; - - it('calls the insertElement method on the squire instance when in WYSIWYG mode', () => { - jest.spyOn(URL, 'createObjectURL'); - mockInstance.editor.isWysiwygMode.mockReturnValue(true); - mockInstance.editor.getSquire.mockReturnValue({ insertElement: jest.fn() }); - - addImage(mockInstance, mockImage, file); - - expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalled(); - expect(global.URL.createObjectURL).toHaveBeenLastCalledWith(file); - }); - - it('calls the insertText method on the instance when in Markdown mode', () => { - mockInstance.editor.isWysiwygMode.mockReturnValue(false); - addImage(mockInstance, mockImage, file); - - expect(mockInstance.editor.insertText).toHaveBeenCalledWith('![some alt text](some/url.png)'); - }); - }); - - describe('insertVideo', () => { - const mockUrl = 'some/url'; - const htmlString = `<figure contenteditable="false" class="gl-relative gl-h-0 video_container"><iframe class="gl-absolute gl-top-0 gl-left-0 gl-w-full gl-h-full" width="560" height="315" frameborder="0" src="some/url"></iframe></figure>`; - const mockInsertElement = jest.fn(); - - beforeEach(() => - mockInstance.editor.getSquire.mockReturnValue({ insertElement: mockInsertElement }), - ); - - describe('WYSIWYG mode', () => { - it('calls the insertElement method on the squire instance with an iFrame element', () => { - mockInstance.editor.isWysiwygMode.mockReturnValue(true); - - insertVideo(mockInstance, mockUrl); - - expect(mockInstance.editor.getSquire().insertElement).toHaveBeenCalledWith( - parseHtml(htmlString), - ); - }); - }); - - describe('Markdown mode', () => { - it('calls the insertText method on the editor instance with the iFrame element HTML', () => { - mockInstance.editor.isWysiwygMode.mockReturnValue(false); - - insertVideo(mockInstance, mockUrl); - - expect(mockInstance.editor.insertText).toHaveBeenCalledWith(htmlString); - }); - }); - }); - - describe('getMarkdown', () => { - it('calls the invoke method on the instance', () => { - getMarkdown(mockInstance); - - expect(mockInstance.invoke).toHaveBeenCalledWith('getMarkdown'); - }); - }); - - describe('registerHTMLToMarkdownRenderer', () => { - let baseRenderer; - const htmlToMarkdownRenderer = {}; - const extendedRenderer = {}; - - beforeEach(() => { - baseRenderer = mockInstance.toMarkOptions.renderer; - buildHTMLToMarkdownRenderer.mockReturnValueOnce(htmlToMarkdownRenderer); - baseRenderer.constructor.factory.mockReturnValueOnce(extendedRenderer); - - registerHTMLToMarkdownRenderer(mockInstance); - }); - - it('builds a new instance of the HTML to Markdown renderer', () => { - expect(buildHTMLToMarkdownRenderer).toHaveBeenCalledWith(baseRenderer); - }); - - it('extends base renderer with the HTML to Markdown renderer', () => { - expect(baseRenderer.constructor.factory).toHaveBeenCalledWith( - baseRenderer, - htmlToMarkdownRenderer, - ); - }); - - it('replaces the default renderer with extended renderer', () => { - expect(mockInstance.toMarkOptions.renderer).toBe(extendedRenderer); - }); - }); - - describe('getEditorOptions', () => { - const externalOptions = { - customRenderers: {}, - }; - const renderer = {}; - - beforeEach(() => { - buildCustomRenderer.mockReturnValueOnce(renderer); - }); - - it('generates a configuration object with a custom HTML renderer and toolbarItems', () => { - expect(getEditorOptions()).toHaveProp('customHTMLRenderer', renderer); - expect(getEditorOptions()).toHaveProp('toolbarItems'); - }); - - it('passes external renderers to the buildCustomRenderers function', () => { - getEditorOptions(externalOptions); - expect(buildCustomRenderer).toHaveBeenCalledWith(externalOptions.customRenderers); - }); - - it('uses the internal sanitizeHTML service for HTML sanitization', () => { - const options = getEditorOptions(); - const html = '<div></div>'; - - options.customHTMLSanitizer(html); - - expect(sanitizeHTML).toHaveBeenCalledWith(html); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js deleted file mode 100644 index 97aecda97d2..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal_spec.js +++ /dev/null @@ -1,73 +0,0 @@ -import { GlModal, GlTabs } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { IMAGE_TABS } from '~/vue_shared/components/rich_content_editor/constants'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; -import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; - -describe('Add Image Modal', () => { - let wrapper; - const propsData = { imageRoot: 'path/to/root/' }; - - const findModal = () => wrapper.find(GlModal); - const findTabs = () => wrapper.find(GlTabs); - const findUploadImageTab = () => wrapper.find(UploadImageTab); - const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); - const findDescriptionInput = () => wrapper.find({ ref: 'descriptionInput' }); - - beforeEach(() => { - wrapper = shallowMount(AddImageModal, { propsData }); - }); - - describe('when content is loaded', () => { - it('renders a modal component', () => { - expect(findModal().exists()).toBe(true); - }); - - it('renders a Tabs component', () => { - expect(findTabs().exists()).toBe(true); - }); - - it('renders an upload image tab', () => { - expect(findUploadImageTab().exists()).toBe(true); - }); - - it('renders an input to add an image URL', () => { - expect(findUrlInput().exists()).toBe(true); - }); - - it('renders an input to add an image description', () => { - expect(findDescriptionInput().exists()).toBe(true); - }); - }); - - describe('add image', () => { - describe('Upload', () => { - it('validates the file', () => { - const preventDefault = jest.fn(); - const description = 'some description'; - const file = { name: 'some_file.png' }; - - wrapper.vm.$refs.uploadImageTab = { validateFile: jest.fn() }; - wrapper.setData({ file, description, tabIndex: IMAGE_TABS.UPLOAD_TAB }); - - findModal().vm.$emit('ok', { preventDefault }); - - expect(wrapper.vm.$refs.uploadImageTab.validateFile).toHaveBeenCalled(); - }); - }); - - describe('URL', () => { - it('emits an addImage event when a valid URL is specified', () => { - const preventDefault = jest.fn(); - const mockImage = { imageUrl: '/some/valid/url.png', description: 'some description' }; - wrapper.setData({ ...mockImage, tabIndex: IMAGE_TABS.URL_TAB }); - - findModal().vm.$emit('ok', { preventDefault }); - expect(preventDefault).not.toHaveBeenCalled(); - expect(wrapper.emitted('addImage')).toEqual([ - [{ imageUrl: mockImage.imageUrl, altText: mockImage.description }], - ]); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js deleted file mode 100644 index 81fd059ce4f..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import UploadImageTab from '~/vue_shared/components/rich_content_editor/modals/add_image/upload_image_tab.vue'; - -describe('Upload Image Tab', () => { - let wrapper; - - beforeEach(() => { - wrapper = shallowMount(UploadImageTab); - }); - - afterEach(() => wrapper.destroy()); - - const triggerInputEvent = (size) => { - const file = { size, name: 'file-name.png' }; - const mockEvent = new Event('input'); - - Object.defineProperty(mockEvent, 'target', { value: { files: [file] } }); - - wrapper.find({ ref: 'fileInput' }).element.dispatchEvent(mockEvent); - - return file; - }; - - describe('onInput', () => { - it.each` - size | fileError - ${2000000000} | ${'Maximum file size is 2MB. Please select a smaller file.'} - ${200} | ${null} - `('validates the file correctly', ({ size, fileError }) => { - triggerInputEvent(size); - - expect(wrapper.vm.fileError).toBe(fileError); - }); - }); - - it('emits input event when file is valid', () => { - const file = triggerInputEvent(200); - - expect(wrapper.emitted('input')).toEqual([[file]]); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js deleted file mode 100644 index 3e9eaf58181..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/modals/insert_video_modal_spec.js +++ /dev/null @@ -1,44 +0,0 @@ -import { GlModal } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; - -describe('Insert Video Modal', () => { - let wrapper; - - const findModal = () => wrapper.find(GlModal); - const findUrlInput = () => wrapper.find({ ref: 'urlInput' }); - - const triggerInsertVideo = (url) => { - const preventDefault = jest.fn(); - findUrlInput().vm.$emit('input', url); - findModal().vm.$emit('primary', { preventDefault }); - }; - - beforeEach(() => { - wrapper = shallowMount(InsertVideoModal); - }); - - afterEach(() => wrapper.destroy()); - - describe('when content is loaded', () => { - it('renders a modal component', () => { - expect(findModal().exists()).toBe(true); - }); - - it('renders an input to add a URL', () => { - expect(findUrlInput().exists()).toBe(true); - }); - }); - - describe('insert video', () => { - it.each` - url | emitted - ${'https://www.youtube.com/embed/someId'} | ${[['https://www.youtube.com/embed/someId']]} - ${'https://www.youtube.com/watch?v=1234'} | ${[['https://www.youtube.com/embed/1234']]} - ${'::youtube.com/invalid/url'} | ${undefined} - `('formats the url correctly', ({ url, emitted }) => { - triggerInsertVideo(url); - expect(wrapper.emitted('insertVideo')).toEqual(emitted); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js deleted file mode 100644 index 47b1abd2ad2..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_integration_spec.js +++ /dev/null @@ -1,69 +0,0 @@ -import Editor from '@toast-ui/editor'; -import buildMarkdownToHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; -import { registerHTMLToMarkdownRenderer } from '~/vue_shared/components/rich_content_editor/services/editor_service'; - -describe('vue_shared/components/rich_content_editor', () => { - let editor; - - const buildEditor = () => { - editor = new Editor({ - el: document.body, - customHTMLRenderer: buildMarkdownToHTMLRenderer(), - }); - - registerHTMLToMarkdownRenderer(editor); - }; - - beforeEach(() => { - buildEditor(); - }); - - describe('HTML to Markdown', () => { - it('uses "-" character list marker in unordered lists', () => { - editor.setHtml('<ul><li>List item 1</li><li>List item 2</li></ul>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('- List item 1\n- List item 2'); - }); - - it('does not increment the list marker in ordered lists', () => { - editor.setHtml('<ol><li>List item 1</li><li>List item 2</li></ol>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('1. List item 1\n1. List item 2'); - }); - - it('indents lists using four spaces', () => { - editor.setHtml('<ul><li>List item 1</li><ul><li>List item 2</li></ul></ul>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('- List item 1\n - List item 2'); - }); - - it('uses * for strong and _ for emphasis text', () => { - editor.setHtml('<strong>strong text</strong><i>emphasis text</i>'); - - const markdown = editor.getMarkdown(); - - expect(markdown).toBe('**strong text**_emphasis text_'); - }); - }); - - describe('Markdown to HTML', () => { - it.each` - input | output - ${'markdown with _emphasized\ntext_'} | ${'<p>markdown with <em>emphasized text</em></p>\n'} - ${'markdown with **strong\ntext**'} | ${'<p>markdown with <strong>strong text</strong></p>\n'} - `( - 'does not transform softbreaks inside (_) and strong (**) nodes into <br/> tags', - ({ input, output }) => { - editor.setMarkdown(input); - - expect(editor.getHtml()).toBe(output); - }, - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js deleted file mode 100644 index 8eb880b3984..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/rich_content_editor_spec.js +++ /dev/null @@ -1,222 +0,0 @@ -import { Editor, mockEditorApi } from '@toast-ui/vue-editor'; -import { shallowMount } from '@vue/test-utils'; -import { - EDITOR_TYPES, - EDITOR_HEIGHT, - EDITOR_PREVIEW_STYLE, - CUSTOM_EVENTS, -} from '~/vue_shared/components/rich_content_editor/constants'; -import AddImageModal from '~/vue_shared/components/rich_content_editor/modals/add_image/add_image_modal.vue'; -import InsertVideoModal from '~/vue_shared/components/rich_content_editor/modals/insert_video_modal.vue'; -import RichContentEditor from '~/vue_shared/components/rich_content_editor/rich_content_editor.vue'; - -import { - addCustomEventListener, - removeCustomEventListener, - addImage, - insertVideo, - registerHTMLToMarkdownRenderer, - getEditorOptions, - getMarkdown, -} from '~/vue_shared/components/rich_content_editor/services/editor_service'; - -jest.mock('~/vue_shared/components/rich_content_editor/services/editor_service', () => ({ - addCustomEventListener: jest.fn(), - removeCustomEventListener: jest.fn(), - addImage: jest.fn(), - insertVideo: jest.fn(), - registerHTMLToMarkdownRenderer: jest.fn(), - getEditorOptions: jest.fn(), - getMarkdown: jest.fn(), -})); - -describe('Rich Content Editor', () => { - let wrapper; - - const content = '## Some Markdown'; - const imageRoot = 'path/to/root/'; - const findEditor = () => wrapper.find({ ref: 'editor' }); - const findAddImageModal = () => wrapper.find(AddImageModal); - const findInsertVideoModal = () => wrapper.find(InsertVideoModal); - - const buildWrapper = async () => { - wrapper = shallowMount(RichContentEditor, { - propsData: { content, imageRoot }, - stubs: { - ToastEditor: Editor, - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('when content is loaded', () => { - const editorOptions = {}; - - beforeEach(() => { - getEditorOptions.mockReturnValueOnce(editorOptions); - buildWrapper(); - }); - - it('renders an editor', () => { - expect(findEditor().exists()).toBe(true); - }); - - it('renders the correct content', () => { - expect(findEditor().props().initialValue).toBe(content); - }); - - it('provides options generated by the getEditorOptions service', () => { - expect(findEditor().props().options).toBe(editorOptions); - }); - - it('has the correct preview style', () => { - expect(findEditor().props().previewStyle).toBe(EDITOR_PREVIEW_STYLE); - }); - - it('has the correct initial edit type', () => { - expect(findEditor().props().initialEditType).toBe(EDITOR_TYPES.wysiwyg); - }); - - it('has the correct height', () => { - expect(findEditor().props().height).toBe(EDITOR_HEIGHT); - }); - }); - - describe('when content is changed', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('emits an input event with the changed content', () => { - const changedMarkdown = '## Changed Markdown'; - getMarkdown.mockReturnValueOnce(changedMarkdown); - - findEditor().vm.$emit('change'); - - expect(wrapper.emitted().input[0][0]).toBe(changedMarkdown); - }); - }); - - describe('when content is reset', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('should reset the content via setMarkdown', () => { - const newContent = 'Just the body content excluding the front matter for example'; - const mockInstance = { invoke: jest.fn() }; - wrapper.vm.$refs.editor = mockInstance; - - wrapper.vm.resetInitialValue(newContent); - - expect(mockInstance.invoke).toHaveBeenCalledWith('setMarkdown', newContent); - }); - }); - - describe('when editor is loaded', () => { - const formattedMarkdown = 'formatted markdown'; - - beforeEach(() => { - mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); - buildWrapper(); - }); - - afterEach(() => { - mockEditorApi.getMarkdown.mockReset(); - }); - - it('adds the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - expect(addCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openAddImageModal, - wrapper.vm.onOpenAddImageModal, - ); - }); - - it('adds the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { - expect(addCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openInsertVideoModal, - wrapper.vm.onOpenInsertVideoModal, - ); - }); - - it('registers HTML to markdown renderer', () => { - expect(registerHTMLToMarkdownRenderer).toHaveBeenCalledWith(wrapper.vm.editorApi); - }); - - it('emits load event with the markdown formatted by Toast UI', () => { - mockEditorApi.getMarkdown.mockReturnValueOnce(formattedMarkdown); - expect(mockEditorApi.getMarkdown).toHaveBeenCalled(); - expect(wrapper.emitted('load')[0]).toEqual([{ formattedMarkdown }]); - }); - }); - - describe('when editor is destroyed', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('removes the CUSTOM_EVENTS.openAddImageModal custom event listener', () => { - wrapper.vm.$destroy(); - - expect(removeCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openAddImageModal, - wrapper.vm.onOpenAddImageModal, - ); - }); - - it('removes the CUSTOM_EVENTS.openInsertVideoModal custom event listener', () => { - wrapper.vm.$destroy(); - - expect(removeCustomEventListener).toHaveBeenCalledWith( - wrapper.vm.editorApi, - CUSTOM_EVENTS.openInsertVideoModal, - wrapper.vm.onOpenInsertVideoModal, - ); - }); - }); - - describe('add image modal', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('renders an addImageModal component', () => { - expect(findAddImageModal().exists()).toBe(true); - }); - - it('calls the onAddImage method when the addImage event is emitted', () => { - const mockImage = { imageUrl: 'some/url.png', altText: 'some description' }; - const mockInstance = { exec: jest.fn() }; - wrapper.vm.$refs.editor = mockInstance; - - findAddImageModal().vm.$emit('addImage', mockImage); - expect(addImage).toHaveBeenCalledWith(mockInstance, mockImage, undefined); - }); - }); - - describe('insert video modal', () => { - beforeEach(() => { - buildWrapper(); - }); - - it('renders an insertVideoModal component', () => { - expect(findInsertVideoModal().exists()).toBe(true); - }); - - it('calls the onInsertVideo method when the insertVideo event is emitted', () => { - const mockUrl = 'https://www.youtube.com/embed/someId'; - const mockInstance = { exec: jest.fn() }; - wrapper.vm.$refs.editor = mockInstance; - - findInsertVideoModal().vm.$emit('insertVideo', mockUrl); - expect(insertVideo).toHaveBeenCalledWith(mockInstance, mockUrl); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js deleted file mode 100644 index a823d04024d..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js +++ /dev/null @@ -1,32 +0,0 @@ -import buildCustomHTMLRenderer from '~/vue_shared/components/rich_content_editor/services/build_custom_renderer'; - -describe('Build Custom Renderer Service', () => { - describe('buildCustomHTMLRenderer', () => { - it('should return an object with the default renderer functions when lacking arguments', () => { - expect(buildCustomHTMLRenderer()).toEqual( - expect.objectContaining({ - htmlBlock: expect.any(Function), - htmlInline: expect.any(Function), - heading: expect.any(Function), - item: expect.any(Function), - paragraph: expect.any(Function), - text: expect.any(Function), - softbreak: expect.any(Function), - }), - ); - }); - - it('should return an object with both custom and default renderer functions when passed customRenderers', () => { - const mockHtmlCustomRenderer = jest.fn(); - const customRenderers = { - html: [mockHtmlCustomRenderer], - }; - - expect(buildCustomHTMLRenderer(customRenderers)).toEqual( - expect.objectContaining({ - html: expect.any(Function), - }), - ); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js deleted file mode 100644 index 3caf03dabba..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ /dev/null @@ -1,218 +0,0 @@ -import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; -import { attributeDefinition } from './renderers/mock_data'; - -describe('rich_content_editor/services/html_to_markdown_renderer', () => { - let baseRenderer; - let htmlToMarkdownRenderer; - let fakeNode; - - beforeEach(() => { - baseRenderer = { - trim: jest.fn((input) => `trimmed ${input}`), - getSpaceCollapsedText: jest.fn((input) => `space collapsed ${input}`), - getSpaceControlled: jest.fn((input) => `space controlled ${input}`), - convert: jest.fn(), - }; - - fakeNode = { nodeValue: 'mock_node', dataset: {} }; - }); - - afterEach(() => { - htmlToMarkdownRenderer = null; - }); - - describe('TEXT_NODE visitor', () => { - it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - - expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe( - `space controlled trimmed space collapsed ${fakeNode.nodeValue}`, - ); - }); - }); - - describe('LI OL, LI UL visitor', () => { - const oneLevelNestedList = '\n * List item 1\n * List item 2'; - const twoLevelNestedList = '\n * List item 1\n * List item 2'; - const spaceInContentList = '\n * List item 1\n * List item 2'; - - it.each` - list | indentSpaces | result - ${oneLevelNestedList} | ${2} | ${'\n * List item 1\n * List item 2'} - ${oneLevelNestedList} | ${3} | ${'\n * List item 1\n * List item 2'} - ${oneLevelNestedList} | ${6} | ${'\n * List item 1\n * List item 2'} - ${twoLevelNestedList} | ${4} | ${'\n * List item 1\n * List item 2'} - ${spaceInContentList} | ${1} | ${'\n * List item 1\n * List item 2'} - `('changes the list indentation to $indentSpaces spaces', ({ list, indentSpaces, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - subListIndentSpaces: indentSpaces, - }); - - baseRenderer.convert.mockReturnValueOnce(list); - - expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list); - }); - }); - - describe('UL LI visitor', () => { - it.each` - listItem | unorderedListBulletChar | result | bulletChar - ${'* list item'} | ${undefined} | ${'- list item'} | ${'default'} - ${' - list item'} | ${'*'} | ${' * list item'} | ${'*'} - ${' * list item'} | ${'-'} | ${' - list item'} | ${'-'} - `( - 'uses $bulletChar bullet char in unordered list items when $unorderedListBulletChar is set in config', - ({ listItem, unorderedListBulletChar, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - unorderedListBulletChar, - }); - baseRenderer.convert.mockReturnValueOnce(listItem); - - expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem); - }, - ); - - it('detects attribute definitions and attaches them to the list item', () => { - const listItem = '- list item'; - const result = `${listItem}\n${attributeDefinition}\n`; - - fakeNode.dataset.attributeDefinition = attributeDefinition; - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`); - - expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); - }); - }); - - describe('OL LI visitor', () => { - it.each` - listItem | result | incrementListMarker | action - ${'2. list item'} | ${'1. list item'} | ${false} | ${'increments'} - ${' 3. list item'} | ${' 1. list item'} | ${false} | ${'increments'} - ${' 123. list item'} | ${' 1. list item'} | ${false} | ${'increments'} - ${'3. list item'} | ${'3. list item'} | ${true} | ${'does not increment'} - `( - '$action a list item counter when incrementListMaker is $incrementListMarker', - ({ listItem, result, incrementListMarker }) => { - const subContent = null; - - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - incrementListMarker, - }); - baseRenderer.convert.mockReturnValueOnce(listItem); - - expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent); - }, - ); - }); - - describe('STRONG, B visitor', () => { - it.each` - input | strongCharacter | result - ${'**strong text**'} | ${'_'} | ${'__strong text__'} - ${'__strong text__'} | ${'*'} | ${'**strong text**'} - `( - 'converts $input to $result when strong character is $strongCharacter', - ({ input, strongCharacter, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - strong: strongCharacter, - }); - - baseRenderer.convert.mockReturnValueOnce(input); - - expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); - }, - ); - }); - - describe('EM, I visitor', () => { - it.each` - input | emphasisCharacter | result - ${'*strong text*'} | ${'_'} | ${'_strong text_'} - ${'_strong text_'} | ${'*'} | ${'*strong text*'} - `( - 'converts $input to $result when emphasis character is $emphasisCharacter', - ({ input, emphasisCharacter, result }) => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer, { - emphasis: emphasisCharacter, - }); - - baseRenderer.convert.mockReturnValueOnce(input); - - expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); - }, - ); - }); - - describe('H1, H2, H3, H4, H5, H6 visitor', () => { - it('detects attribute definitions and attaches them to the heading', () => { - const heading = 'heading text'; - const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`; - - fakeNode.dataset.attributeDefinition = attributeDefinition; - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`); - - expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result); - }); - }); - - describe('PRE CODE', () => { - let node; - const subContent = 'sub content'; - const originalConverterResult = 'base result'; - - beforeEach(() => { - node = document.createElement('PRE'); - - node.innerText = 'reference definition content'; - node.dataset.sseReferenceDefinition = true; - - baseRenderer.convert.mockReturnValueOnce(originalConverterResult); - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - }); - - it('returns raw text when pre node has sse-reference-definitions class', () => { - expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe( - `\n\n${node.innerText}\n\n`, - ); - }); - - it('returns base result when pre node does not have sse-reference-definitions class', () => { - delete node.dataset.sseReferenceDefinition; - - expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); - }); - }); - - describe('IMG', () => { - const originalSrc = 'path/to/image.png'; - const alt = 'alt text'; - let node; - - beforeEach(() => { - node = document.createElement('img'); - node.alt = alt; - node.src = originalSrc; - }); - - it('returns an image with its original src of the `original-src` attribute is preset', () => { - node.dataset.originalSrc = originalSrc; - node.src = 'modified/path/to/image.png'; - - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - - expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); - }); - - it('fallback to `src` if no `original-src` is specified on the image', () => { - htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - expect(htmlToMarkdownRenderer.IMG(node)).toBe(`![${alt}](${originalSrc})`); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js deleted file mode 100644 index 7a7e3055520..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token_spec.js +++ /dev/null @@ -1,88 +0,0 @@ -import { - buildTextToken, - buildUneditableOpenTokens, - buildUneditableCloseToken, - buildUneditableCloseTokens, - buildUneditableBlockTokens, - buildUneditableInlineTokens, - buildUneditableHtmlAsTextTokens, -} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; - -import { - originInlineToken, - originToken, - uneditableOpenTokens, - uneditableCloseToken, - uneditableCloseTokens, - uneditableBlockTokens, - uneditableInlineTokens, - uneditableTokens, -} from './mock_data'; - -describe('Build Uneditable Token renderer helper', () => { - describe('buildTextToken', () => { - it('returns an object literal representing a text token', () => { - const text = originToken.content; - expect(buildTextToken(text)).toStrictEqual(originToken); - }); - }); - - describe('buildUneditableOpenTokens', () => { - it('returns a 2-item array of tokens with the originToken appended to an open token', () => { - const result = buildUneditableOpenTokens(originToken); - - expect(result).toHaveLength(2); - expect(result).toStrictEqual(uneditableOpenTokens); - }); - }); - - describe('buildUneditableCloseToken', () => { - it('returns an object literal representing the uneditable close token', () => { - expect(buildUneditableCloseToken()).toStrictEqual(uneditableCloseToken); - }); - }); - - describe('buildUneditableCloseTokens', () => { - it('returns a 2-item array of tokens with the originToken prepended to a close token', () => { - const result = buildUneditableCloseTokens(originToken); - - expect(result).toHaveLength(2); - expect(result).toStrictEqual(uneditableCloseTokens); - }); - }); - - describe('buildUneditableBlockTokens', () => { - it('returns a 3-item array of tokens with the originToken wrapped in the middle of block tokens', () => { - const result = buildUneditableBlockTokens(originToken); - - expect(result).toHaveLength(3); - expect(result).toStrictEqual(uneditableTokens); - }); - }); - - describe('buildUneditableInlineTokens', () => { - it('returns a 3-item array of tokens with the originInlineToken wrapped in the middle of inline tokens', () => { - const result = buildUneditableInlineTokens(originInlineToken); - - expect(result).toHaveLength(3); - expect(result).toStrictEqual(uneditableInlineTokens); - }); - }); - - describe('buildUneditableHtmlAsTextTokens', () => { - it('returns a 3-item array of tokens with the htmlBlockNode wrapped as a text token in the middle of block tokens', () => { - const htmlBlockNode = { - type: 'htmlBlock', - literal: '<div data-tomark-pass ><h1>Some header</h1><p>Some paragraph</p></div>', - }; - const result = buildUneditableHtmlAsTextTokens(htmlBlockNode); - const { type, content } = result[1]; - - expect(type).toBe('text'); - expect(content).not.toMatch(/ data-tomark-pass /); - - expect(result).toHaveLength(3); - expect(result).toStrictEqual(uneditableBlockTokens); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js deleted file mode 100644 index 407072fb596..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js +++ /dev/null @@ -1,54 +0,0 @@ -// Node spec helpers - -export const buildMockTextNode = (literal) => ({ literal, type: 'text' }); - -export const normalTextNode = buildMockTextNode('This is just normal text.'); - -// Token spec helpers - -const buildMockUneditableOpenToken = (type) => { - return { - type: 'openTag', - tagName: type, - attributes: { contenteditable: false }, - classNames: [ - 'gl-px-4 gl-py-2 gl-my-5 gl-opacity-5 gl-bg-gray-100 gl-user-select-none gl-cursor-not-allowed', - ], - }; -}; - -const buildMockTextToken = (content) => { - return { - type: 'text', - tagName: null, - content, - }; -}; - -const buildMockUneditableCloseToken = (type) => ({ type: 'closeTag', tagName: type }); - -export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}'); -const uneditableOpenToken = buildMockUneditableOpenToken('div'); -export const uneditableOpenTokens = [uneditableOpenToken, originToken]; -export const uneditableCloseToken = buildMockUneditableCloseToken('div'); -export const uneditableCloseTokens = [originToken, uneditableCloseToken]; -export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; - -export const originInlineToken = { - type: 'text', - content: '<i>Inline</i> content', -}; - -export const uneditableInlineTokens = [ - buildMockUneditableOpenToken('a'), - originInlineToken, - buildMockUneditableCloseToken('a'), -]; - -export const uneditableBlockTokens = [ - uneditableOpenToken, - buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'), - uneditableCloseToken, -]; - -export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js deleted file mode 100644 index 69fd9a67a21..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js +++ /dev/null @@ -1,25 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition'; -import { attributeDefinition } from './mock_data'; - -describe('rich_content_editor/renderers/render_attribute_definition', () => { - describe('canRender', () => { - it.each` - input | result - ${{ literal: attributeDefinition }} | ${true} - ${{ literal: `FOO${attributeDefinition}` }} | ${false} - ${{ literal: `${attributeDefinition}BAR` }} | ${false} - ${{ literal: 'foobar' }} | ${false} - `('returns $result when input is $input', ({ input, result }) => { - expect(renderer.canRender(input)).toBe(result); - }); - }); - - describe('render', () => { - it('returns an empty HTML comment', () => { - expect(renderer.render()).toEqual({ - type: 'html', - content: '<!-- sse-attribute-definition -->', - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js deleted file mode 100644 index 0c59d9f569b..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_embedded_ruby_text'; -import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -import { buildMockTextNode, normalTextNode } from './mock_data'; - -const embeddedRubyTextNode = buildMockTextNode('<%= partial("some/path") %>'); - -describe('Render Embedded Ruby Text renderer', () => { - describe('canRender', () => { - it('should return true when the argument `literal` has embedded ruby syntax', () => { - expect(renderer.canRender(embeddedRubyTextNode)).toBe(true); - }); - - it('should return false when the argument `literal` lacks embedded ruby syntax', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should delegate rendering to the renderUneditableLeaf util', () => { - expect(renderer.render).toBe(renderUneditableLeaf); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js deleted file mode 100644 index c1aaed6f0c3..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline_spec.js +++ /dev/null @@ -1,33 +0,0 @@ -import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_font_awesome_html_inline'; - -import { normalTextNode } from './mock_data'; - -const fontAwesomeInlineHtmlNode = { - firstChild: null, - literal: '<i class="far fa-paper-plane" id="biz-tech-icons">', - type: 'html', -}; - -describe('Render Font Awesome Inline HTML renderer', () => { - describe('canRender', () => { - it('should return true when the argument `literal` has font awesome inline html syntax', () => { - expect(renderer.canRender(fontAwesomeInlineHtmlNode)).toBe(true); - }); - - it('should return false when the argument `literal` lacks font awesome inline html syntax', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should return uneditable inline tokens', () => { - const token = { type: 'text', tagName: null, content: fontAwesomeInlineHtmlNode.literal }; - const context = { origin: () => token }; - - expect(renderer.render(fontAwesomeInlineHtmlNode, context)).toStrictEqual( - buildUneditableInlineTokens(token), - ); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js deleted file mode 100644 index 76abc1ec3d8..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js +++ /dev/null @@ -1,12 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading'; -import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -describe('rich_content_editor/renderers/render_heading', () => { - it('canRender delegates to renderUtils.willAlwaysRender', () => { - expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); - }); - - it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { - expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js deleted file mode 100644 index 234f6a4d4ca..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_html_block_spec.js +++ /dev/null @@ -1,37 +0,0 @@ -import { buildUneditableHtmlAsTextTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_html_block'; - -describe('rich_content_editor/services/renderers/render_html_block', () => { - const htmlBlockNode = { - literal: '<div><h1>Heading</h1><p>Paragraph.</p></div>', - type: 'htmlBlock', - }; - - describe('canRender', () => { - it.each` - input | result - ${htmlBlockNode} | ${true} - ${{ literal: '<iframe></iframe>', type: 'htmlBlock' }} | ${true} - ${{ literal: '<iframe src="https://www.youtube.com"></iframe>', type: 'htmlBlock' }} | ${false} - ${{ literal: '<iframe></iframe>', type: 'text' }} | ${false} - `('returns $result when input=$input', ({ input, result }) => { - expect(renderer.canRender(input)).toBe(result); - }); - }); - - describe('render', () => { - const htmlBlockNodeToMark = { - firstChild: null, - literal: '<div data-to-mark ></div>', - type: 'htmlBlock', - }; - - it.each` - node - ${htmlBlockNode} - ${htmlBlockNodeToMark} - `('should return uneditable tokens wrapping the $node as a token', ({ node }) => { - expect(renderer.render(node)).toStrictEqual(buildUneditableHtmlAsTextTokens(node)); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js deleted file mode 100644 index 425d0f41bcd..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text_spec.js +++ /dev/null @@ -1,55 +0,0 @@ -import { buildUneditableInlineTokens } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_instance_text'; - -import { buildMockTextNode, normalTextNode } from './mock_data'; - -const mockTextStart = 'Majority example '; -const mockTextMiddle = '[environment terraform plans][terraform]'; -const mockTextEnd = '.'; -const identifierInstanceStartTextNode = buildMockTextNode(mockTextStart); -const identifierInstanceEndTextNode = buildMockTextNode(mockTextEnd); - -describe('Render Identifier Instance Text renderer', () => { - describe('canRender', () => { - it.each` - node | target - ${normalTextNode} | ${false} - ${identifierInstanceStartTextNode} | ${false} - ${identifierInstanceEndTextNode} | ${false} - ${buildMockTextNode(mockTextMiddle)} | ${true} - ${buildMockTextNode('Minority example [environment terraform plans][]')} | ${true} - ${buildMockTextNode('Minority example [environment terraform plans]')} | ${true} - `( - 'should return $target when the $node validates against identifier instance syntax', - ({ node, target }) => { - expect(renderer.canRender(node)).toBe(target); - }, - ); - }); - - describe('render', () => { - it.each` - start | middle | end - ${mockTextStart} | ${mockTextMiddle} | ${mockTextEnd} - ${mockTextStart} | ${'[environment terraform plans][]'} | ${mockTextEnd} - ${mockTextStart} | ${'[environment terraform plans]'} | ${mockTextEnd} - `( - 'should return inline editable, uneditable, and editable tokens in sequence', - ({ start, middle, end }) => { - const buildMockTextToken = (content) => ({ type: 'text', tagName: null, content }); - - const startToken = buildMockTextToken(start); - const middleToken = buildMockTextToken(middle); - const endToken = buildMockTextToken(end); - - const content = `${start}${middle}${end}`; - const contentToken = buildMockTextToken(content); - const contentNode = buildMockTextNode(content); - const context = { origin: jest.fn().mockReturnValueOnce(contentToken) }; - expect(renderer.render(contentNode, context)).toStrictEqual( - [startToken, buildUneditableInlineTokens(middleToken), endToken].flat(), - ); - }, - ); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js deleted file mode 100644 index 470cf9bddaa..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; - -import { buildMockTextNode } from './mock_data'; - -const buildMockParagraphNode = (literal) => { - return { - firstChild: buildMockTextNode(literal), - type: 'paragraph', - }; -}; - -const normalParagraphNode = buildMockParagraphNode( - 'This is just normal paragraph. It has multiple sentences.', -); -const identifierParagraphNode = buildMockParagraphNode( - `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, -); - -describe('rich_content_editor/renderers_render_identifier_paragraph', () => { - describe('canRender', () => { - it.each` - node | paragraph | target - ${identifierParagraphNode} | ${'[Some text]: https://link.com'} | ${true} - ${normalParagraphNode} | ${'Normal non-identifier text. Another sentence.'} | ${false} - `( - 'should return $target when the $node matches $paragraph syntax', - ({ node, paragraph, target }) => { - const context = { - entering: true, - getChildrenText: jest.fn().mockReturnValueOnce(paragraph), - }; - - expect(renderer.canRender(node, context)).toBe(target); - }, - ); - }); - - describe('render', () => { - let context; - let result; - - beforeEach(() => { - const node = { - firstChild: { - type: 'text', - literal: '[Some text]: https://link.com', - next: { - type: 'linebreak', - next: { - type: 'text', - literal: '[identifier]: http://example1.com "title"', - }, - }, - }, - }; - context = { skipChildren: jest.fn() }; - result = renderer.render(node, context); - }); - - it('renders the reference definitions as a code block', () => { - expect(result).toEqual([ - { - type: 'openTag', - tagName: 'pre', - classNames: ['code-block', 'language-markdown'], - attributes: { - 'data-sse-reference-definition': true, - }, - }, - { type: 'openTag', tagName: 'code' }, - { - type: 'text', - content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', - }, - { type: 'closeTag', tagName: 'code' }, - { type: 'closeTag', tagName: 'pre' }, - ]); - }); - - it('skips the reference definition node children from rendering', () => { - expect(context.skipChildren).toHaveBeenCalled(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js deleted file mode 100644 index c1ab700535b..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js +++ /dev/null @@ -1,12 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item'; -import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -describe('rich_content_editor/renderers/render_list_item', () => { - it('canRender delegates to renderUtils.willAlwaysRender', () => { - expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); - }); - - it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { - expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js deleted file mode 100644 index 3c3d2354cb9..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_softbreak_spec.js +++ /dev/null @@ -1,23 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_softbreak'; - -describe('Render softbreak renderer', () => { - describe('canRender', () => { - it.each` - node | parentType | result - ${{ parent: { type: 'emph' } }} | ${'emph'} | ${true} - ${{ parent: { type: 'strong' } }} | ${'strong'} | ${true} - ${{ parent: { type: 'paragraph' } }} | ${'paragraph'} | ${false} - `('returns $result when node parent type is $parentType ', ({ node, result }) => { - expect(renderer.canRender(node)).toBe(result); - }); - }); - - describe('render', () => { - it('returns text node with a break line', () => { - expect(renderer.render()).toEqual({ - type: 'text', - content: ' ', - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js deleted file mode 100644 index 7c1809c290c..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js +++ /dev/null @@ -1,109 +0,0 @@ -import { - buildUneditableBlockTokens, - buildUneditableOpenTokens, -} from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import { - renderUneditableLeaf, - renderUneditableBranch, - renderWithAttributeDefinitions, - willAlwaysRender, -} from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; - -describe('rich_content_editor/renderers/render_utils', () => { - describe('renderUneditableLeaf', () => { - it('should return uneditable block tokens around an origin token', () => { - const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; - const result = renderUneditableLeaf({}, context); - - expect(result).toStrictEqual(buildUneditableBlockTokens(originToken)); - }); - }); - - describe('renderUneditableBranch', () => { - let origin; - - beforeEach(() => { - origin = jest.fn().mockReturnValueOnce(originToken); - }); - - it('should return uneditable block open token followed by the origin token when entering', () => { - const context = { entering: true, origin }; - const result = renderUneditableBranch({}, context); - - expect(result).toStrictEqual(buildUneditableOpenTokens(originToken)); - }); - - it('should return uneditable block closing token when exiting', () => { - const context = { entering: false, origin }; - const result = renderUneditableBranch({}, context); - - expect(result).toStrictEqual(uneditableCloseToken); - }); - }); - - describe('willAlwaysRender', () => { - it('always returns true', () => { - expect(willAlwaysRender()).toBe(true); - }); - }); - - describe('renderWithAttributeDefinitions', () => { - let openTagToken; - let closeTagToken; - let node; - const attributes = { - 'data-attribute-definition': attributeDefinition, - }; - - beforeEach(() => { - openTagToken = { type: 'openTag' }; - closeTagToken = { type: 'closeTag' }; - node = { - next: { - firstChild: { - literal: attributeDefinition, - }, - }, - }; - }); - - describe('when token type is openTag', () => { - it('attaches attributes when attributes exist in the node’s next sibling', () => { - const context = { origin: () => openTagToken }; - - expect(renderWithAttributeDefinitions(node, context)).toEqual({ - ...openTagToken, - attributes, - }); - }); - - it('attaches attributes when attributes exist in the node’s children', () => { - const context = { origin: () => openTagToken }; - node = { - firstChild: { - firstChild: { - next: { - next: { - literal: attributeDefinition, - }, - }, - }, - }, - }; - - expect(renderWithAttributeDefinitions(node, context)).toEqual({ - ...openTagToken, - attributes, - }); - }); - }); - - it('does not attach attributes when token type is "closeTag"', () => { - const context = { origin: () => closeTagToken }; - - expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js deleted file mode 100644 index f2182ef60d7..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/sanitize_html_spec.js +++ /dev/null @@ -1,11 +0,0 @@ -import sanitizeHTML from '~/vue_shared/components/rich_content_editor/services/sanitize_html'; - -describe('rich_content_editor/services/sanitize_html', () => { - it.each` - input | result - ${'<iframe src="https://www.youtube.com"></iframe>'} | ${'<iframe src="https://www.youtube.com"></iframe>'} - ${'<iframe src="https://gitlab.com"></iframe>'} | ${''} - `('removes iframes if the iframe source origin is not allowed', ({ input, result }) => { - expect(sanitizeHTML(input)).toBe(result); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js deleted file mode 100644 index 5a56b499769..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/toolbar_item_spec.js +++ /dev/null @@ -1,57 +0,0 @@ -import { GlIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import ToolbarItem from '~/vue_shared/components/rich_content_editor/toolbar_item.vue'; - -describe('Toolbar Item', () => { - let wrapper; - - const findIcon = () => wrapper.find(GlIcon); - const findButton = () => wrapper.find('button'); - - const buildWrapper = (propsData) => { - wrapper = shallowMount(ToolbarItem, { - propsData, - directives: { - GlTooltip: createMockDirective(), - }, - }); - }; - - describe.each` - icon | tooltip - ${'heading'} | ${'Headings'} - ${'bold'} | ${'Add bold text'} - ${'italic'} | ${'Add italic text'} - ${'strikethrough'} | ${'Add strikethrough text'} - ${'quote'} | ${'Insert a quote'} - ${'link'} | ${'Add a link'} - ${'doc-code'} | ${'Insert a code block'} - ${'list-bulleted'} | ${'Add a bullet list'} - ${'list-numbered'} | ${'Add a numbered list'} - ${'list-task'} | ${'Add a task list'} - ${'list-indent'} | ${'Indent'} - ${'list-outdent'} | ${'Outdent'} - ${'dash'} | ${'Add a line'} - ${'table'} | ${'Add a table'} - ${'code'} | ${'Insert an image'} - ${'code'} | ${'Insert inline code'} - `('toolbar item component', ({ icon, tooltip }) => { - beforeEach(() => buildWrapper({ icon, tooltip })); - - it('renders a toolbar button', () => { - expect(findButton().exists()).toBe(true); - }); - - it('renders the correct tooltip', () => { - const buttonTooltip = getBinding(wrapper.element, 'gl-tooltip'); - expect(buttonTooltip).toBeDefined(); - expect(buttonTooltip.value.title).toBe(tooltip); - }); - - it(`renders the ${icon} icon`, () => { - expect(findIcon().exists()).toBe(true); - expect(findIcon().props().name).toBe(icon); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap new file mode 100644 index 00000000000..b2906973dbd --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/__snapshots__/runner_aws_deployments_modal_spec.js.snap @@ -0,0 +1,110 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`RunnerAwsDeploymentsModal renders the modal 1`] = ` +<gl-modal-stub + actionsecondary="[object Object]" + dismisslabel="Close" + modalclass="" + modalid="runner-aws-deployments-modal" + size="sm" + title="Deploy GitLab Runner in AWS" + titletag="h4" +> + <p> + For each solution, you will choose a capacity. 1 enables warm HA through Auto Scaling group re-spawn. 2 enables hot HA because the service is available even when a node is lost. 3 or more enables hot HA and manual scaling of runner fleet. + </p> + + <ul + class="gl-list-style-none gl-p-0 gl-mb-0" + > + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-amazon-linux-2-docker-manual-scaling-with-schedule-ondemandonly.cf.yml&stackName=linux-docker-nonspot¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="linux-docker-nonspot" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="linux-docker-nonspot" + width="46" + /> + + Amazon Linux 2 Docker HA with manual scaling and optional scheduling. Non-spot. Default choice for Linux Docker executor. + + </gl-link-stub> + </li> + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-amazon-linux-2-docker-manual-scaling-with-schedule-spotonly.cf.yml&stackName=linux-docker-spotonly¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="linux-docker-spotonly" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="linux-docker-spotonly" + width="46" + /> + + Amazon Linux 2 Docker HA with manual scaling and optional scheduling. 100% spot. + + </gl-link-stub> + </li> + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-windows2019-shell-manual-scaling-with-scheduling-ondemandonly.cf.yml&stackName=win2019-shell-non-spot¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="win2019-shell-non-spot" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="win2019-shell-non-spot" + width="46" + /> + + Windows 2019 Shell with manual scaling and optional scheduling. Non-spot. Default choice for Windows Shell executor. + + </gl-link-stub> + </li> + <li> + <gl-link-stub + class="gl-display-flex gl-font-weight-bold" + href="https://us-west-2.console.aws.amazon.com/cloudformation/home?region=us-west-2#/stacks/create/review?templateURL=https%3A%2F%2Fgl-public-templates.s3.amazonaws.com%2Fcfn%2Fexperimental%2Feasybutton-windows2019-shell-manual-scaling-with-scheduling-spotonly.cf.yml&stackName=win2019-shell-spot¶m_3GITLABRunnerInstanceURL=http%3A%2F%2Ftest.host" + target="_blank" + > + <img + alt="win2019-shell-spot" + class="gl-mt-2 gl-mr-5 gl-mb-6" + height="46" + src="/assets/aws-cloud-formation.png" + title="win2019-shell-spot" + width="46" + /> + + Windows 2019 Shell with manual scaling and optional scheduling. 100% spot. + + </gl-link-stub> + </li> + </ul> + + <p> + <gl-sprintf-stub + message="Don't see what you are looking for? See the full list of options, including a fully customizable option, %{linkStart}here%{linkEnd}." + /> + </p> + + <p + class="gl-font-sm gl-mb-0" + > + If you do not select an AWS VPC, the runner will deploy to the Default VPC in the AWS Region you select. Please consult with your AWS administrator to understand if there are any security risks to deploying into the Default VPC in any given region in your AWS account. + </p> +</gl-modal-stub> +`; diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js new file mode 100644 index 00000000000..69db3ec7132 --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal_spec.js @@ -0,0 +1,75 @@ +import { GlLink } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import ExperimentTracking from '~/experimentation/experiment_tracking'; +import { getBaseURL } from '~/lib/utils/url_utility'; +import { + EXPERIMENT_NAME, + CF_BASE_URL, + TEMPLATES_BASE_URL, + EASY_BUTTONS, +} from '~/vue_shared/components/runner_aws_deployments/constants'; +import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; + +jest.mock('~/experimentation/experiment_tracking'); + +describe('RunnerAwsDeploymentsModal', () => { + let wrapper; + + const findEasyButtons = () => wrapper.findAllComponents(GlLink); + + const createComponent = () => { + wrapper = shallowMount(RunnerAwsDeploymentsModal, { + propsData: { + modalId: 'runner-aws-deployments-modal', + }, + }); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders the modal', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + + it('should contain all easy buttons', () => { + expect(findEasyButtons()).toHaveLength(EASY_BUTTONS.length); + }); + + describe('first easy button', () => { + const findFirstButton = () => findEasyButtons().at(0); + + it('should contain the correct description', () => { + expect(findFirstButton().text()).toBe(EASY_BUTTONS[0].description); + }); + + it('should contain the correct link', () => { + const link = findFirstButton().attributes('href'); + + expect(link.startsWith(CF_BASE_URL)).toBe(true); + expect( + link.includes( + `templateURL=${encodeURIComponent(TEMPLATES_BASE_URL + EASY_BUTTONS[0].templateName)}`, + ), + ).toBe(true); + expect(link.includes(`stackName=${EASY_BUTTONS[0].stackName}`)).toBe(true); + expect( + link.includes(`param_3GITLABRunnerInstanceURL=${encodeURIComponent(getBaseURL())}`), + ).toBe(true); + }); + + it('should track an event when clicked', () => { + findFirstButton().vm.$emit('click'); + + expect(ExperimentTracking).toHaveBeenCalledWith(EXPERIMENT_NAME); + expect(ExperimentTracking.prototype.event).toHaveBeenCalledWith( + `template_clicked_${EASY_BUTTONS[0].stackName}`, + ); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js new file mode 100644 index 00000000000..639668761ea --- /dev/null +++ b/spec/frontend/vue_shared/components/runner_aws_deployments/runner_aws_deployments_spec.js @@ -0,0 +1,41 @@ +import { shallowMount } from '@vue/test-utils'; +import { nextTick } from 'vue'; +import { extendedWrapper } from 'helpers/vue_test_utils_helper'; +import RunnerAwsDeployments from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments.vue'; +import RunnerAwsDeploymentsModal from '~/vue_shared/components/runner_aws_deployments/runner_aws_deployments_modal.vue'; + +describe('RunnerAwsDeployments component', () => { + let wrapper; + + const findModalButton = () => wrapper.findByTestId('show-modal-button'); + const findModal = () => wrapper.findComponent(RunnerAwsDeploymentsModal); + + const createComponent = () => { + wrapper = extendedWrapper(shallowMount(RunnerAwsDeployments)); + }; + + beforeEach(() => { + createComponent(); + }); + + afterEach(() => { + wrapper.destroy(); + }); + + it('should show the "Deploy GitLab Runner in AWS" button', () => { + expect(findModalButton().exists()).toBe(true); + expect(findModalButton().text()).toBe('Deploy GitLab Runner in AWS'); + }); + + it('should not render the modal once mounted', () => { + expect(findModal().exists()).toBe(false); + }); + + it('should render the modal once clicked', async () => { + findModalButton().vm.$emit('click'); + + await nextTick(); + + expect(findModal().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js new file mode 100644 index 00000000000..d58c87d66cb --- /dev/null +++ b/spec/frontend/vue_shared/components/security_reports/artifact_downloads/merge_request_artifact_download_spec.js @@ -0,0 +1,108 @@ +import { shallowMount } from '@vue/test-utils'; +import Vue from 'vue'; +import VueApollo from 'vue-apollo'; +import createMockApollo from 'helpers/mock_apollo_helper'; +import { + expectedDownloadDropdownProps, + securityReportMergeRequestDownloadPathsQueryResponse, +} from 'jest/vue_shared/security_reports/mock_data'; +import createFlash from '~/flash'; +import Component from '~/vue_shared/security_reports/components/artifact_downloads/merge_request_artifact_download.vue'; +import SecurityReportDownloadDropdown from '~/vue_shared/security_reports/components/security_report_download_dropdown.vue'; +import { + REPORT_TYPE_SAST, + REPORT_TYPE_SECRET_DETECTION, +} from '~/vue_shared/security_reports/constants'; +import securityReportMergeRequestDownloadPathsQuery from '~/vue_shared/security_reports/queries/security_report_merge_request_download_paths.query.graphql'; + +jest.mock('~/flash'); + +describe('Merge request artifact Download', () => { + let wrapper; + + const defaultProps = { + reportTypes: [REPORT_TYPE_SAST, REPORT_TYPE_SECRET_DETECTION], + targetProjectFullPath: '/path', + mrIid: 123, + }; + + const createWrapper = ({ propsData, options }) => { + wrapper = shallowMount(Component, { + stubs: { + SecurityReportDownloadDropdown, + }, + propsData: { + ...defaultProps, + ...propsData, + }, + ...options, + }); + }; + + const pendingHandler = () => new Promise(() => {}); + const successHandler = () => + Promise.resolve({ data: securityReportMergeRequestDownloadPathsQueryResponse }); + const failureHandler = () => Promise.resolve({ errors: [{ message: 'some error' }] }); + const createMockApolloProvider = (handler) => { + Vue.use(VueApollo); + const requestHandlers = [[securityReportMergeRequestDownloadPathsQuery, handler]]; + + return createMockApollo(requestHandlers); + }; + + const findDownloadDropdown = () => wrapper.find(SecurityReportDownloadDropdown); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('given the query is loading', () => { + beforeEach(() => { + createWrapper({ + options: { + apolloProvider: createMockApolloProvider(pendingHandler), + }, + }); + }); + + it('loading is true', () => { + expect(findDownloadDropdown().props('loading')).toBe(true); + }); + }); + + describe('given the query loads successfully', () => { + beforeEach(() => { + createWrapper({ + options: { + apolloProvider: createMockApolloProvider(successHandler), + }, + }); + }); + + it('renders the download dropdown', () => { + expect(findDownloadDropdown().props()).toEqual(expectedDownloadDropdownProps); + }); + }); + + describe('given the query fails', () => { + beforeEach(() => { + createWrapper({ + options: { + apolloProvider: createMockApolloProvider(failureHandler), + }, + }); + }); + + it('calls createFlash correctly', () => { + expect(createFlash).toHaveBeenCalledWith({ + message: Component.i18n.apiError, + captureError: true, + error: expect.any(Error), + }); + }); + + it('renders nothing', () => { + expect(findDownloadDropdown().props('artifacts')).toEqual([]); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js deleted file mode 100644 index 68ea94e72ce..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/base_spec.js +++ /dev/null @@ -1,127 +0,0 @@ -import { shallowMount } from '@vue/test-utils'; -import Vue from 'vue'; - -import LabelsSelect from '~/labels_select'; -import BaseComponent from '~/vue_shared/components/sidebar/labels_select/base.vue'; - -import { mockConfig, mockLabels } from './mock_data'; - -const createComponent = (config = mockConfig) => - shallowMount(BaseComponent, { - propsData: config, - }); - -describe('BaseComponent', () => { - let wrapper; - let vm; - - beforeEach((done) => { - wrapper = createComponent(); - - ({ vm } = wrapper); - - Vue.nextTick(done); - }); - - afterEach(() => { - wrapper.destroy(); - }); - - describe('computed', () => { - describe('hiddenInputName', () => { - it('returns correct string when showCreate prop is `true`', () => { - expect(vm.hiddenInputName).toBe('issue[label_names][]'); - }); - - it('returns correct string when showCreate prop is `false`', async () => { - await wrapper.setProps({ showCreate: false }); - - expect(vm.hiddenInputName).toBe('label_id[]'); - }); - }); - - describe('createLabelTitle', () => { - it('returns `Create project label` when `isProject` prop is true', () => { - expect(vm.createLabelTitle).toBe('Create project label'); - }); - - it('return `Create group label` when `isProject` prop is false', async () => { - await wrapper.setProps({ isProject: false }); - - expect(vm.createLabelTitle).toBe('Create group label'); - }); - }); - - describe('manageLabelsTitle', () => { - it('returns `Manage project labels` when `isProject` prop is true', () => { - expect(vm.manageLabelsTitle).toBe('Manage project labels'); - }); - - it('return `Manage group labels` when `isProject` prop is false', async () => { - await wrapper.setProps({ isProject: false }); - - expect(vm.manageLabelsTitle).toBe('Manage group labels'); - }); - }); - }); - - describe('methods', () => { - describe('handleClick', () => { - it('emits onLabelClick event with label and list of labels as params', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleClick(mockLabels[0]); - - expect(vm.$emit).toHaveBeenCalledWith('onLabelClick', mockLabels[0]); - }); - }); - - describe('handleCollapsedValueClick', () => { - it('emits toggleCollapse event on component', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleCollapsedValueClick(); - - expect(vm.$emit).toHaveBeenCalledWith('toggleCollapse'); - }); - }); - - describe('handleDropdownHidden', () => { - it('emits onDropdownClose event on component', () => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.handleDropdownHidden(); - - expect(vm.$emit).toHaveBeenCalledWith('onDropdownClose'); - }); - }); - }); - - describe('mounted', () => { - it('creates LabelsSelect object and assigns it to `labelsDropdon` as prop', () => { - expect(vm.labelsDropdown instanceof LabelsSelect).toBe(true); - }); - }); - - describe('template', () => { - it('renders component container element with classes `block labels`', () => { - expect(vm.$el.classList.contains('block')).toBe(true); - expect(vm.$el.classList.contains('labels')).toBe(true); - }); - - it('renders `.selectbox` element', () => { - expect(vm.$el.querySelector('.selectbox')).not.toBeNull(); - expect(vm.$el.querySelector('.selectbox').getAttribute('style')).toBe('display: none;'); - }); - - it('renders `.dropdown` element', () => { - expect(vm.$el.querySelector('.dropdown')).not.toBeNull(); - }); - - it('renders `.dropdown-menu` element', () => { - const dropdownMenuEl = vm.$el.querySelector('.dropdown-menu'); - - expect(dropdownMenuEl).not.toBeNull(); - expect(dropdownMenuEl.querySelector('.dropdown-page-one')).not.toBeNull(); - expect(dropdownMenuEl.querySelector('.dropdown-content')).not.toBeNull(); - expect(dropdownMenuEl.querySelector('.dropdown-loading')).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js deleted file mode 100644 index 79851e5db05..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_button_spec.js +++ /dev/null @@ -1,90 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownButtonComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_button.vue'; - -import { mockConfig, mockLabels } from './mock_data'; - -const componentConfig = { - ...mockConfig, - fieldName: 'label_id[]', - labels: mockLabels, - showExtraOptions: false, -}; - -const createComponent = (config = componentConfig) => { - const Component = Vue.extend(dropdownButtonComponent); - - return mountComponent(Component, config); -}; - -describe('DropdownButtonComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('computed', () => { - describe('dropdownToggleText', () => { - it('returns text as `Label` when `labels` prop is empty array', () => { - const mockEmptyLabels = { ...componentConfig, labels: [] }; - const vmEmptyLabels = createComponent(mockEmptyLabels); - - expect(vmEmptyLabels.dropdownToggleText).toBe('Label'); - vmEmptyLabels.$destroy(); - }); - - it('returns first label name with remaining label count when `labels` prop has more than one item', () => { - const mockMoreLabels = { ...componentConfig, labels: mockLabels.concat(mockLabels) }; - const vmMoreLabels = createComponent(mockMoreLabels); - - expect(vmMoreLabels.dropdownToggleText).toBe( - `Foo Label +${mockMoreLabels.labels.length - 1} more`, - ); - vmMoreLabels.$destroy(); - }); - - it('returns first label name when `labels` prop has only one item present', () => { - const singleLabel = { ...componentConfig, labels: [mockLabels[0]] }; - const vmSingleLabel = createComponent(singleLabel); - - expect(vmSingleLabel.dropdownToggleText).toBe(mockLabels[0].title); - - vmSingleLabel.$destroy(); - }); - }); - }); - - describe('template', () => { - it('renders component container element of type `button`', () => { - expect(vm.$el.nodeName).toBe('BUTTON'); - }); - - it('renders component container element with required data attributes', () => { - expect(vm.$el.dataset.abilityName).toBe(vm.abilityName); - expect(vm.$el.dataset.fieldName).toBe(vm.fieldName); - expect(vm.$el.dataset.issueUpdate).toBe(vm.updatePath); - expect(vm.$el.dataset.labels).toBe(vm.labelsPath); - expect(vm.$el.dataset.namespacePath).toBe(vm.namespace); - expect(vm.$el.dataset.showAny).not.toBeDefined(); - }); - - it('renders dropdown toggle text element', () => { - const dropdownToggleTextEl = vm.$el.querySelector('.dropdown-toggle-text'); - - expect(dropdownToggleTextEl).not.toBeNull(); - expect(dropdownToggleTextEl.innerText.trim()).toBe('Foo Label +1 more'); - }); - - it('renders dropdown button icon', () => { - const dropdownIconEl = vm.$el.querySelector('.dropdown-menu-toggle .gl-icon'); - - expect(dropdownIconEl).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js deleted file mode 100644 index 322e632da02..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ /dev/null @@ -1,103 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownCreateLabelComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_create_label.vue'; - -import { mockSuggestedColors } from './mock_data'; - -const createComponent = (headerTitle) => { - const Component = Vue.extend(dropdownCreateLabelComponent); - - return mountComponent(Component, { - headerTitle, - }); -}; - -describe('DropdownCreateLabelComponent', () => { - const colorsCount = Object.keys(mockSuggestedColors).length; - let vm; - - beforeEach(() => { - gon.suggested_label_colors = mockSuggestedColors; - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('created', () => { - it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => { - expect(vm.suggestedColors.length).toBe(colorsCount); - }); - }); - - describe('template', () => { - it('renders component container element with classes `dropdown-page-two dropdown-new-label`', () => { - expect(vm.$el.classList.contains('dropdown-page-two', 'dropdown-new-label')).toBe(true); - }); - - it('renders `Go back` button on component header', () => { - const backButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-back'); - - expect(backButtonEl).not.toBe(null); - expect(backButtonEl.querySelector('[data-testid="arrow-left-icon"]')).not.toBe(null); - }); - - it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => { - const headerEl = vm.$el.querySelector('.dropdown-title'); - - expect(headerEl.innerText.trim()).toContain('Create new label'); - }); - - it('renders component header element with value of `headerTitle` prop', () => { - const headerTitle = 'Create project label'; - const vmWithHeaderTitle = createComponent(headerTitle); - const headerEl = vmWithHeaderTitle.$el.querySelector('.dropdown-title'); - - expect(headerEl.innerText.trim()).toContain(headerTitle); - vmWithHeaderTitle.$destroy(); - }); - - it('renders `Close` button on component header', () => { - const closeButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-close'); - - expect(closeButtonEl).not.toBe(null); - }); - - it('renders `Name new label` input element', () => { - expect(vm.$el.querySelector('.dropdown-labels-error.js-label-error')).not.toBe(null); - expect(vm.$el.querySelector('input#new_label_name.default-dropdown-input')).not.toBe(null); - }); - - it('renders suggested colors list elements', () => { - const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown'); - - expect(colorsListContainerEl).not.toBe(null); - expect(colorsListContainerEl.querySelectorAll('a').length).toBe(colorsCount); - - const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0]; - - expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0].colorCode); - expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 153, 102);'); - }); - - it('renders color input element', () => { - expect(vm.$el.querySelector('.dropdown-label-color-input')).not.toBe(null); - expect( - vm.$el.querySelector('.dropdown-label-color-preview.js-dropdown-label-color-preview'), - ).not.toBe(null); - - expect(vm.$el.querySelector('input#new_label_color.default-dropdown-input')).not.toBe(null); - }); - - it('renders component action buttons', () => { - const createBtnEl = vm.$el.querySelector('button.js-new-label-btn'); - const cancelBtnEl = vm.$el.querySelector('button.js-cancel-label-btn'); - - expect(createBtnEl).not.toBe(null); - expect(createBtnEl.innerText.trim()).toBe('Create'); - expect(cancelBtnEl.innerText.trim()).toBe('Cancel'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js deleted file mode 100644 index 7e9e242a4f5..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_footer_spec.js +++ /dev/null @@ -1,75 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownFooterComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_footer.vue'; - -import { mockConfig } from './mock_data'; - -const createComponent = ( - labelsWebUrl = mockConfig.labelsWebUrl, - createLabelTitle, - manageLabelsTitle, -) => { - const Component = Vue.extend(dropdownFooterComponent); - - return mountComponent(Component, { - labelsWebUrl, - createLabelTitle, - manageLabelsTitle, - }); -}; - -describe('DropdownFooterComponent', () => { - const createLabelTitle = 'Create project label'; - const manageLabelsTitle = 'Manage project labels'; - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders link element with `Create new label` when `createLabelTitle` prop is not provided', () => { - const createLabelEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-toggle-page'); - - expect(createLabelEl).not.toBeNull(); - expect(createLabelEl.innerText.trim()).toBe('Create new label'); - }); - - it('renders link element with value of `createLabelTitle` prop', () => { - const vmWithCreateLabelTitle = createComponent(mockConfig.labelsWebUrl, createLabelTitle); - const createLabelEl = vmWithCreateLabelTitle.$el.querySelector( - '.dropdown-footer-list .dropdown-toggle-page', - ); - - expect(createLabelEl.innerText.trim()).toBe(createLabelTitle); - vmWithCreateLabelTitle.$destroy(); - }); - - it('renders link element with `Manage labels` when `manageLabelsTitle` prop is not provided', () => { - const manageLabelsEl = vm.$el.querySelector('.dropdown-footer-list .dropdown-external-link'); - - expect(manageLabelsEl).not.toBeNull(); - expect(manageLabelsEl.getAttribute('href')).toBe(vm.labelsWebUrl); - expect(manageLabelsEl.innerText.trim()).toBe('Manage labels'); - }); - - it('renders link element with value of `manageLabelsTitle` prop', () => { - const vmWithManageLabelsTitle = createComponent( - mockConfig.labelsWebUrl, - createLabelTitle, - manageLabelsTitle, - ); - const manageLabelsEl = vmWithManageLabelsTitle.$el.querySelector( - '.dropdown-footer-list .dropdown-external-link', - ); - - expect(manageLabelsEl.innerText.trim()).toBe(manageLabelsTitle); - vmWithManageLabelsTitle.$destroy(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js deleted file mode 100644 index 0b9a7262e41..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownHeaderComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_header.vue'; - -const createComponent = () => { - const Component = Vue.extend(dropdownHeaderComponent); - - return mountComponent(Component); -}; - -describe('DropdownHeaderComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders header text element', () => { - const headerEl = vm.$el.querySelector('.dropdown-title span'); - - expect(headerEl.innerText.trim()).toBe('Assign labels'); - }); - - it('renders `Close` button element', () => { - const closeBtnEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-close', - ); - - expect(closeBtnEl).not.toBeNull(); - expect(closeBtnEl.querySelector('.dropdown-menu-close-icon')).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js deleted file mode 100644 index 510e537b1cd..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_search_input_spec.js +++ /dev/null @@ -1,39 +0,0 @@ -import Vue from 'vue'; - -import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownSearchInputComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_search_input.vue'; - -const createComponent = () => { - const Component = Vue.extend(dropdownSearchInputComponent); - - return mountComponent(Component); -}; - -describe('DropdownSearchInputComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.$destroy(); - }); - - describe('template', () => { - it('renders input element with type `search`', () => { - const inputEl = vm.$el.querySelector('input.dropdown-input-field'); - - expect(inputEl).not.toBeNull(); - expect(inputEl.getAttribute('type')).toBe('search'); - }); - - it('renders search icon element', () => { - expect(vm.$el.querySelector('.dropdown-input-search')).not.toBeNull(); - }); - - it('renders clear search icon element', () => { - expect(vm.$el.querySelector('.dropdown-input-clear.js-dropdown-input-clear')).not.toBeNull(); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js deleted file mode 100644 index 30dd92b72a4..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_title_spec.js +++ /dev/null @@ -1,41 +0,0 @@ -import { GlLoadingIcon } from '@gitlab/ui'; -import { shallowMount } from '@vue/test-utils'; -import dropdownTitleComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_title.vue'; - -const createComponent = (canEdit = true) => - shallowMount(dropdownTitleComponent, { - propsData: { - canEdit, - }, - }); - -describe('DropdownTitleComponent', () => { - let wrapper; - - beforeEach(() => { - wrapper = createComponent(); - }); - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - describe('template', () => { - it('renders title text', () => { - expect(wrapper.vm.$el.classList.contains('title', 'hide-collapsed')).toBe(true); - expect(wrapper.vm.$el.innerText.trim()).toContain('Labels'); - }); - - it('renders spinner icon element', () => { - expect(wrapper.find(GlLoadingIcon)).not.toBeNull(); - }); - - it('renders `Edit` button element', () => { - const editBtnEl = wrapper.vm.$el.querySelector('button.edit-link.js-sidebar-dropdown-toggle'); - - expect(editBtnEl).not.toBeNull(); - expect(editBtnEl.innerText.trim()).toBe('Edit'); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js deleted file mode 100644 index 37f59c108df..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_spec.js +++ /dev/null @@ -1,84 +0,0 @@ -import { GlLabel } from '@gitlab/ui'; -import { mount } from '@vue/test-utils'; -import DropdownValueComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value.vue'; - -import { mockConfig, mockLabels } from './mock_data'; - -const createComponent = ( - labels = mockLabels, - labelFilterBasePath = mockConfig.labelFilterBasePath, -) => - mount(DropdownValueComponent, { - propsData: { - labels, - labelFilterBasePath, - enableScopedLabels: true, - }, - stubs: { - GlLabel: true, - }, - }); - -describe('DropdownValueComponent', () => { - let vm; - - beforeEach(() => { - vm = createComponent(); - }); - - afterEach(() => { - vm.destroy(); - }); - - describe('computed', () => { - describe('isEmpty', () => { - it('returns true if `labels` prop is empty', () => { - const vmEmptyLabels = createComponent([]); - - expect(vmEmptyLabels.classes()).not.toContain('has-labels'); - vmEmptyLabels.destroy(); - }); - - it('returns false if `labels` prop is empty', () => { - expect(vm.classes()).toContain('has-labels'); - }); - }); - }); - - describe('methods', () => { - describe('labelFilterUrl', () => { - it('returns URL string starting with labelFilterBasePath and encoded label.title', () => { - expect(vm.find(GlLabel).props('target')).toBe( - '/gitlab-org/my-project/issues?label_name[]=Foo%20Label', - ); - }); - }); - - describe('showScopedLabels', () => { - it('returns true if the label is scoped label', () => { - const labels = vm.findAll(GlLabel); - expect(labels.length).toEqual(2); - expect(labels.at(1).props('scoped')).toBe(true); - }); - }); - }); - - describe('template', () => { - it('renders component container element with classes `hide-collapsed value issuable-show-labels`', () => { - expect(vm.classes()).toContain('hide-collapsed', 'value', 'issuable-show-labels'); - }); - - it('render slot content inside component when `labels` prop is empty', () => { - const vmEmptyLabels = createComponent([]); - - expect(vmEmptyLabels.find('.text-secondary').text().trim()).toBe(mockConfig.emptyValueText); - vmEmptyLabels.destroy(); - }); - - it('renders DropdownValueComponent element', () => { - const labelEl = vm.find(GlLabel); - - expect(labelEl.exists()).toBe(true); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js deleted file mode 100644 index 73716d4edf3..00000000000 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js +++ /dev/null @@ -1,57 +0,0 @@ -export const mockLabels = [ - { - id: 26, - title: 'Foo Label', - description: 'Foobar', - color: '#BADA55', - text_color: '#FFFFFF', - }, - { - id: 27, - title: 'Foo::Bar', - description: 'Foobar', - color: '#0033CC', - text_color: '#FFFFFF', - }, -]; - -export const mockSuggestedColors = { - '#009966': 'Green-cyan', - '#8fbc8f': 'Dark sea green', - '#3cb371': 'Medium sea green', - '#00b140': 'Green screen', - '#013220': 'Dark green', - '#6699cc': 'Blue-gray', - '#0000ff': 'Blue', - '#e6e6fa': 'Lavendar', - '#9400d3': 'Dark violet', - '#330066': 'Deep violet', - '#808080': 'Gray', - '#36454f': 'Charcoal grey', - '#f7e7ce': 'Champagne', - '#c21e56': 'Rose red', - '#cc338b': 'Magenta-pink', - '#dc143c': 'Crimson', - '#ff0000': 'Red', - '#cd5b45': 'Dark coral', - '#eee600': 'Titanium yellow', - '#ed9121': 'Carrot orange', - '#c39953': 'Aztec Gold', -}; - -export const mockConfig = { - showCreate: true, - isProject: true, - abilityName: 'issue', - context: { - labels: mockLabels, - }, - namespace: 'gitlab-org', - updatePath: '/gitlab-org/my-project/issue/1', - labelsPath: '/gitlab-org/my-project/-/labels.json', - labelsWebUrl: '/gitlab-org/my-project/-/labels', - labelFilterBasePath: '/gitlab-org/my-project/issues', - canEdit: true, - suggestedColors: mockSuggestedColors, - emptyValueText: 'None', -}; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js index 003f3d2b4e6..8c1693e8dcc 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed_spec.js @@ -1,9 +1,9 @@ import Vue from 'vue'; import mountComponent from 'helpers/vue_mount_component_helper'; -import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; +import dropdownValueCollapsedComponent from '~/vue_shared/components/sidebar/labels_select_vue/dropdown_value_collapsed.vue'; -import { mockLabels } from './mock_data'; +import { mockCollapsedLabels as mockLabels } from './mock_data'; const createComponent = (labels = mockLabels) => { const Component = Vue.extend(dropdownValueCollapsedComponent); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index 3f00eab17b7..be849789667 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -2,12 +2,12 @@ import { shallowMount, createLocalVue } from '@vue/test-utils'; import Vuex from 'vuex'; import { isInViewport } from '~/lib/utils/common_utils'; -import DropdownValueCollapsed from '~/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed.vue'; 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'; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js index f293b8422e7..730afcbecab 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/mock_data.js @@ -33,6 +33,23 @@ export const mockLabels = [ }, ]; +export const mockCollapsedLabels = [ + { + id: 26, + title: 'Foo Label', + description: 'Foobar', + color: '#BADA55', + text_color: '#FFFFFF', + }, + { + id: 27, + title: 'Foo::Bar', + description: 'Foobar', + color: '#0033CC', + text_color: '#FFFFFF', + }, +]; + export const mockConfig = { allowLabelEdit: true, allowLabelCreate: true, diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js b/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js new file mode 100644 index 00000000000..7ca8c619ffc --- /dev/null +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_mock_data.js @@ -0,0 +1,30 @@ +export const userCalloutsResponse = (callouts = []) => ({ + data: { + currentUser: { + id: 'gid://gitlab/User/46', + __typename: 'UserCore', + callouts: { + __typename: 'UserCalloutConnection', + nodes: callouts.map((callout) => ({ + __typename: 'UserCallout', + featureName: callout.toUpperCase(), + dismissedAt: '2021-02-12T11:10:01Z', + })), + }, + }, + }, +}); + +export const anonUserCalloutsResponse = () => ({ data: { currentUser: null } }); + +export const userCalloutMutationResponse = (variables, errors = []) => ({ + data: { + userCalloutCreate: { + errors, + userCallout: { + featureName: variables.input.featureName.toUpperCase(), + dismissedAt: '2021-02-12T11:10:01Z', + }, + }, + }, +}); diff --git a/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js new file mode 100644 index 00000000000..70dec42ab32 --- /dev/null +++ b/spec/frontend/vue_shared/components/user_callout_dismisser_spec.js @@ -0,0 +1,306 @@ +import { mount } from '@vue/test-utils'; +import { merge } from 'lodash'; +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 dismissUserCalloutMutation from '~/graphql_shared/mutations/dismiss_user_callout.mutation.graphql'; +import getUserCalloutsQuery from '~/graphql_shared/queries/get_user_callouts.query.graphql'; +import UserCalloutDismisser from '~/vue_shared/components/user_callout_dismisser.vue'; +import { + anonUserCalloutsResponse, + userCalloutMutationResponse, + userCalloutsResponse, +} from './user_callout_dismisser_mock_data'; + +Vue.use(VueApollo); + +const initialSlotProps = (changes = {}) => ({ + dismiss: expect.any(Function), + isAnonUser: false, + isDismissed: false, + isLoadingQuery: true, + isLoadingMutation: false, + mutationError: null, + queryError: null, + shouldShowCallout: false, + ...changes, +}); + +describe('UserCalloutDismisser', () => { + let wrapper; + + const MOCK_FEATURE_NAME = 'mock_feature_name'; + + // Query handlers + const successHandlerFactory = (dismissedCallouts = []) => async () => + userCalloutsResponse(dismissedCallouts); + const anonUserHandler = async () => anonUserCalloutsResponse(); + const errorHandler = () => Promise.reject(new Error('query error')); + const pendingHandler = () => new Promise(() => {}); + + // Mutation handlers + const mutationSuccessHandlerSpy = jest.fn(async (variables) => + userCalloutMutationResponse(variables), + ); + const mutationErrorHandlerSpy = jest.fn(async (variables) => + userCalloutMutationResponse(variables, ['mutation error']), + ); + + const defaultScopedSlotSpy = jest.fn(); + + const callDismissSlotProp = () => defaultScopedSlotSpy.mock.calls[0][0].dismiss(); + + const createComponent = ({ queryHandler, mutationHandler, ...options }) => { + wrapper = mount( + UserCalloutDismisser, + merge( + { + propsData: { + featureName: MOCK_FEATURE_NAME, + }, + scopedSlots: { + default: defaultScopedSlotSpy, + }, + apolloProvider: createMockApollo([ + [getUserCalloutsQuery, queryHandler], + [dismissUserCalloutMutation, mutationHandler], + ]), + }, + options, + ), + ); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + describe('when loading', () => { + beforeEach(() => { + createComponent({ + queryHandler: pendingHandler, + }); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith(initialSlotProps()); + }); + }); + + describe('when loaded and dismissed', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory([MOCK_FEATURE_NAME]), + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('when loaded and not dismissed', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + }); + }); + + describe('when loaded with errors', () => { + beforeEach(() => { + createComponent({ + queryHandler: errorHandler, + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + queryError: expect.any(Error), + }), + ); + }); + }); + + describe('when loaded and the user is anonymous', () => { + beforeEach(() => { + createComponent({ + queryHandler: anonUserHandler, + }); + + return waitForPromises(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isAnonUser: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('when skipQuery is true', () => { + let queryHandler; + beforeEach(() => { + queryHandler = jest.fn(); + + createComponent({ + queryHandler, + propsData: { + skipQuery: true, + }, + }); + }); + + it('does not run the query', async () => { + expect(queryHandler).not.toHaveBeenCalled(); + + await waitForPromises(); + + expect(queryHandler).not.toHaveBeenCalled(); + }); + + it('passes expected slot props to child', () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + }); + }); + + describe('dismissing', () => { + describe('given it succeeds', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + mutationHandler: mutationSuccessHandlerSpy, + }); + + return waitForPromises(); + }); + + it('dismissing calls mutation', () => { + expect(mutationSuccessHandlerSpy).not.toHaveBeenCalled(); + + callDismissSlotProp(); + + expect(mutationSuccessHandlerSpy).toHaveBeenCalledWith({ + input: { featureName: MOCK_FEATURE_NAME }, + }); + }); + + it('passes expected slot props to child', async () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + + callDismissSlotProp(); + + // Wait for Vue re-render due to prop change + await nextTick(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingMutation: true, + isLoadingQuery: false, + }), + ); + + // Wait for mutation to resolve + await waitForPromises(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + }), + ); + }); + }); + + describe('given it fails', () => { + beforeEach(() => { + createComponent({ + queryHandler: successHandlerFactory(), + mutationHandler: mutationErrorHandlerSpy, + }); + + return waitForPromises(); + }); + + it('calls mutation', () => { + expect(mutationErrorHandlerSpy).not.toHaveBeenCalled(); + + callDismissSlotProp(); + + expect(mutationErrorHandlerSpy).toHaveBeenCalledWith({ + input: { featureName: MOCK_FEATURE_NAME }, + }); + }); + + it('passes expected slot props to child', async () => { + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isLoadingQuery: false, + shouldShowCallout: true, + }), + ); + + callDismissSlotProp(); + + // Wait for Vue re-render due to prop change + await nextTick(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingMutation: true, + isLoadingQuery: false, + }), + ); + + // Wait for mutation to resolve + await waitForPromises(); + + expect(defaultScopedSlotSpy).lastCalledWith( + initialSlotProps({ + isDismissed: true, + isLoadingQuery: false, + mutationError: ['mutation error'], + }), + ); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_select_spec.js b/spec/frontend/vue_shared/components/user_select_spec.js index 5a609568220..0fabc6525ea 100644 --- a/spec/frontend/vue_shared/components/user_select_spec.js +++ b/spec/frontend/vue_shared/components/user_select_spec.js @@ -49,10 +49,13 @@ describe('User select dropdown', () => { const findUnassignLink = () => wrapper.find('[data-testid="unassign"]'); const findEmptySearchResults = () => wrapper.find('[data-testid="empty-results"]'); + const searchQueryHandlerSuccess = jest.fn().mockResolvedValue(projectMembersResponse); + const participantsQueryHandlerSuccess = jest.fn().mockResolvedValue(participantsQueryResponse); + const createComponent = ({ props = {}, - searchQueryHandler = jest.fn().mockResolvedValue(projectMembersResponse), - participantsQueryHandler = jest.fn().mockResolvedValue(participantsQueryResponse), + searchQueryHandler = searchQueryHandlerSuccess, + participantsQueryHandler = participantsQueryHandlerSuccess, } = {}) => { fakeApollo = createMockApollo([ [searchUsersQuery, searchQueryHandler], @@ -91,6 +94,14 @@ describe('User select dropdown', () => { expect(findParticipantsLoading().exists()).toBe(true); }); + it('skips the queries if `isEditing` prop is false', () => { + createComponent({ props: { isEditing: false } }); + + expect(findParticipantsLoading().exists()).toBe(false); + expect(searchQueryHandlerSuccess).not.toHaveBeenCalled(); + expect(participantsQueryHandlerSuccess).not.toHaveBeenCalled(); + }); + it('emits an `error` event if participants query was rejected', async () => { createComponent({ participantsQueryHandler: mockError }); await waitForPromises(); 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 eb23a8ef457..5a6c91bda9f 100644 --- a/spec/frontend/vue_shared/components/web_ide_link_spec.js +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -3,8 +3,8 @@ import ActionsButton from '~/vue_shared/components/actions_button.vue'; import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; -const TEST_EDIT_URL = '/gitlab-test/test/-/edit/master/'; -const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/'; +const TEST_EDIT_URL = '/gitlab-test/test/-/edit/main/'; +const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/main/-/'; const TEST_GITPOD_URL = 'https://gitpod.test/'; const ACTION_EDIT = { |