diff options
Diffstat (limited to 'spec/frontend/vue_shared/components')
68 files changed, 2913 insertions, 1027 deletions
diff --git a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap index e84eb7789d3..dfd114a2d1c 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/clone_dropdown_spec.js.snap @@ -1,7 +1,7 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` -<gl-new-dropdown-stub +<gl-dropdown-stub category="primary" headertext="" right="" @@ -12,9 +12,9 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` <div class="pb-2 mx-1" > - <gl-new-dropdown-header-stub> + <gl-dropdown-section-header-stub> Clone with SSH - </gl-new-dropdown-header-stub> + </gl-dropdown-section-header-stub> <div class="mx-3" @@ -53,9 +53,9 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` </div> </div> - <gl-new-dropdown-header-stub> + <gl-dropdown-section-header-stub> Clone with HTTP - </gl-new-dropdown-header-stub> + </gl-dropdown-section-header-stub> <div class="mx-3" @@ -94,5 +94,5 @@ exports[`Clone Dropdown Button rendering matches the snapshot 1`] = ` </div> </div> </div> -</gl-new-dropdown-stub> +</gl-dropdown-stub> `; diff --git a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap index cd4728baeaa..c2b97f1e7f9 100644 --- a/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap +++ b/spec/frontend/vue_shared/components/__snapshots__/expand_button_spec.js.snap @@ -4,7 +4,7 @@ exports[`Expand button on click when short text is provided renders button after <span> <button aria-label="Click to expand text" - class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" style="display: none;" type="button" > @@ -32,7 +32,7 @@ exports[`Expand button on click when short text is provided renders button after <button aria-label="Click to expand text" - class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-append text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" style="" type="button" > @@ -56,7 +56,7 @@ exports[`Expand button when short text is provided renders button before text 1` <span> <button aria-label="Click to expand text" - class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-prepend text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" type="button" > <!----> @@ -83,7 +83,7 @@ exports[`Expand button when short text is provided renders button before text 1` <button aria-label="Click to expand text" - class="btn js-text-expander-append text-expander btn-blank btn-default btn-md btn-icon button-ellipsis-horizontal gl-button" + class="btn js-text-expander-append text-expander btn-blank btn-default btn-md gl-button btn-icon button-ellipsis-horizontal" style="display: none;" type="button" > diff --git a/spec/frontend/vue_shared/components/actions_button_spec.js b/spec/frontend/vue_shared/components/actions_button_spec.js new file mode 100644 index 00000000000..4dde9d726d1 --- /dev/null +++ b/spec/frontend/vue_shared/components/actions_button_spec.js @@ -0,0 +1,203 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlDropdown, GlLink } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import ActionsButton from '~/vue_shared/components/actions_button.vue'; + +const TEST_ACTION = { + key: 'action1', + text: 'Sample', + secondaryText: 'Lorem ipsum.', + tooltip: '', + href: '/sample', + attrs: { 'data-test': '123' }, +}; +const TEST_ACTION_2 = { + key: 'action2', + text: 'Sample 2', + secondaryText: 'Dolar sit amit.', + tooltip: 'Dolar sit amit.', + href: '#', + attrs: { 'data-test': '456' }, +}; +const TEST_TOOLTIP = 'Lorem ipsum dolar sit'; + +describe('Actions button component', () => { + let wrapper; + + function createComponent(props) { + wrapper = shallowMount(ActionsButton, { + propsData: { ...props }, + directives: { GlTooltip: createMockDirective() }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const getTooltip = child => { + const directiveBinding = getBinding(child.element, 'gl-tooltip'); + + return directiveBinding.value; + }; + const findLink = () => wrapper.find(GlLink); + const findLinkTooltip = () => getTooltip(findLink()); + const findDropdown = () => wrapper.find(GlDropdown); + const findDropdownTooltip = () => getTooltip(findDropdown()); + const parseDropdownItems = () => + findDropdown() + .findAll('gl-dropdown-item-stub,gl-dropdown-divider-stub') + .wrappers.map(x => { + if (x.is('gl-dropdown-divider-stub')) { + return { type: 'divider' }; + } + + const { isCheckItem, isChecked, secondaryText } = x.props(); + + return { + type: 'item', + isCheckItem, + isChecked, + secondaryText, + text: x.text(), + }; + }); + const clickOn = (child, evt = new Event('click')) => child.vm.$emit('click', evt); + const clickLink = (...args) => clickOn(findLink(), ...args); + const clickDropdown = (...args) => clickOn(findDropdown(), ...args); + + describe('with 1 action', () => { + beforeEach(() => { + createComponent({ actions: [TEST_ACTION] }); + }); + + it('should not render dropdown', () => { + expect(findDropdown().exists()).toBe(false); + }); + + it('should render single button', () => { + const link = findLink(); + + expect(link.attributes()).toEqual({ + class: expect.any(String), + href: TEST_ACTION.href, + ...TEST_ACTION.attrs, + }); + expect(link.text()).toBe(TEST_ACTION.text); + }); + + it('should have tooltip', () => { + expect(findLinkTooltip()).toBe(TEST_ACTION.tooltip); + }); + + it('should have attrs', () => { + expect(findLink().attributes()).toMatchObject(TEST_ACTION.attrs); + }); + + it('can click', () => { + expect(clickLink).not.toThrow(); + }); + }); + + describe('with 1 action with tooltip', () => { + it('should have tooltip', () => { + createComponent({ actions: [{ ...TEST_ACTION, tooltip: TEST_TOOLTIP }] }); + + expect(findLinkTooltip()).toBe(TEST_TOOLTIP); + }); + }); + + describe('with 1 action with handle', () => { + it('can click and trigger handle', () => { + const handleClick = jest.fn(); + createComponent({ actions: [{ ...TEST_ACTION, handle: handleClick }] }); + + const event = new Event('click'); + clickLink(event); + + expect(handleClick).toHaveBeenCalledWith(event); + }); + }); + + describe('with multiple actions', () => { + let handleAction; + + beforeEach(() => { + handleAction = jest.fn(); + + createComponent({ actions: [{ ...TEST_ACTION, handle: handleAction }, TEST_ACTION_2] }); + }); + + it('should default to selecting first action', () => { + expect(findDropdown().attributes()).toMatchObject({ + text: TEST_ACTION.text, + 'split-href': TEST_ACTION.href, + }); + }); + + it('should handle first action click', () => { + const event = new Event('click'); + + clickDropdown(event); + + expect(handleAction).toHaveBeenCalledWith(event); + }); + + it('should render dropdown items', () => { + expect(parseDropdownItems()).toEqual([ + { + type: 'item', + isCheckItem: true, + isChecked: true, + secondaryText: TEST_ACTION.secondaryText, + text: TEST_ACTION.text, + }, + { type: 'divider' }, + { + type: 'item', + isCheckItem: true, + isChecked: false, + secondaryText: TEST_ACTION_2.secondaryText, + text: TEST_ACTION_2.text, + }, + ]); + }); + + it('should select action 2 when clicked', () => { + expect(wrapper.emitted('select')).toBeUndefined(); + + const action2 = wrapper.find(`[data-testid="action_${TEST_ACTION_2.key}"]`); + action2.vm.$emit('click'); + + expect(wrapper.emitted('select')).toEqual([[TEST_ACTION_2.key]]); + }); + + it('should have tooltip value', () => { + expect(findDropdownTooltip()).toBe(TEST_ACTION.tooltip); + }); + }); + + describe('with multiple actions and selectedKey', () => { + beforeEach(() => { + createComponent({ actions: [TEST_ACTION, TEST_ACTION_2], selectedKey: TEST_ACTION_2.key }); + }); + + it('should show action 2 as selected', () => { + expect(parseDropdownItems()).toEqual([ + expect.objectContaining({ + type: 'item', + isChecked: false, + }), + { type: 'divider' }, + expect.objectContaining({ + type: 'item', + isChecked: true, + }), + ]); + }); + + it('should have tooltip value', () => { + expect(findDropdownTooltip()).toBe(TEST_ACTION_2.tooltip); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/alert_detail_table_spec.js b/spec/frontend/vue_shared/components/alert_detail_table_spec.js new file mode 100644 index 00000000000..9c38ccad8a7 --- /dev/null +++ b/spec/frontend/vue_shared/components/alert_detail_table_spec.js @@ -0,0 +1,74 @@ +import { mount } from '@vue/test-utils'; +import { GlTable, GlLoadingIcon } from '@gitlab/ui'; +import AlertDetailsTable from '~/vue_shared/components/alert_details_table.vue'; + +const mockAlert = { + iid: '1527542', + title: 'SyntaxError: Invalid or unexpected token', + severity: 'CRITICAL', + eventCount: 7, + createdAt: '2020-04-17T23:18:14.996Z', + startedAt: '2020-04-17T23:18:14.996Z', + endedAt: '2020-04-17T23:18:14.996Z', + status: 'TRIGGERED', + assignees: { nodes: [] }, + notes: { nodes: [] }, + todos: { nodes: [] }, +}; + +describe('AlertDetails', () => { + let wrapper; + + function mountComponent(propsData = {}) { + wrapper = mount(AlertDetailsTable, { + propsData: { + alert: mockAlert, + loading: false, + ...propsData, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTableComponent = () => wrapper.find(GlTable); + + describe('Alert details', () => { + describe('empty state', () => { + beforeEach(() => { + mountComponent({ alert: null }); + }); + + it('shows an empty state when no alert is provided', () => { + expect(wrapper.text()).toContain('No alert data to display.'); + }); + }); + + describe('loading state', () => { + beforeEach(() => { + mountComponent({ loading: true }); + }); + + it('displays a loading state when loading', () => { + expect(wrapper.find(GlLoadingIcon).exists()).toBe(true); + }); + }); + + describe('with table data', () => { + beforeEach(() => { + mountComponent(); + }); + + it('renders a table', () => { + expect(findTableComponent().exists()).toBe(true); + }); + + it('renders a cell based on alert data', () => { + expect(findTableComponent().text()).toContain('SyntaxError: Invalid or unexpected token'); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js index 5cf42ecdc1d..22643a17b2b 100644 --- a/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js +++ b/spec/frontend/vue_shared/components/blob_viewers/rich_viewer_spec.js @@ -36,6 +36,6 @@ describe('Blob Rich Viewer component', () => { }); it('is using Markdown View Field', () => { - expect(wrapper.contains(MarkdownFieldView)).toBe(true); + expect(wrapper.find(MarkdownFieldView).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/changed_file_icon_spec.js b/spec/frontend/vue_shared/components/changed_file_icon_spec.js index 03519a6f803..80918c5e771 100644 --- a/spec/frontend/vue_shared/components/changed_file_icon_spec.js +++ b/spec/frontend/vue_shared/components/changed_file_icon_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import ChangedFileIcon from '~/vue_shared/components/changed_file_icon.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const changedFile = () => ({ changed: true }); const stagedFile = () => ({ changed: true, staged: true }); @@ -25,7 +25,7 @@ describe('Changed file icon', () => { wrapper.destroy(); }); - const findIcon = () => wrapper.find(Icon); + const findIcon = () => wrapper.find(GlIcon); const findIconName = () => findIcon().props('name'); const findIconClasses = () => findIcon().classes(); const findTooltipText = () => wrapper.attributes('title'); diff --git a/spec/frontend/vue_shared/components/clone_dropdown_spec.js b/spec/frontend/vue_shared/components/clone_dropdown_spec.js index d9829874b93..5b8576ad761 100644 --- a/spec/frontend/vue_shared/components/clone_dropdown_spec.js +++ b/spec/frontend/vue_shared/components/clone_dropdown_spec.js @@ -1,5 +1,5 @@ import { shallowMount } from '@vue/test-utils'; -import { GlFormInputGroup, GlNewDropdownHeader } from '@gitlab/ui'; +import { GlFormInputGroup, GlDropdownSectionHeader } from '@gitlab/ui'; import CloneDropdown from '~/vue_shared/components/clone_dropdown.vue'; describe('Clone Dropdown Button', () => { @@ -40,7 +40,7 @@ describe('Clone Dropdown Button', () => { createComponent(); const group = wrapper.findAll(GlFormInputGroup).at(index); expect(group.props('value')).toBe(value); - expect(group.contains(GlFormInputGroup)).toBe(true); + expect(group.find(GlFormInputGroup).exists()).toBe(true); }); it.each` @@ -51,7 +51,7 @@ describe('Clone Dropdown Button', () => { createComponent({ [name]: value }); expect(wrapper.find(GlFormInputGroup).props('value')).toBe(value); - expect(wrapper.findAll(GlNewDropdownHeader).length).toBe(1); + expect(wrapper.findAll(GlDropdownSectionHeader).length).toBe(1); }); }); @@ -63,12 +63,12 @@ describe('Clone Dropdown Button', () => { `('allows null values for the props', ({ name, value }) => { createComponent({ ...defaultPropsData, [name]: value }); - expect(wrapper.findAll(GlNewDropdownHeader).length).toBe(1); + expect(wrapper.findAll(GlDropdownSectionHeader).length).toBe(1); }); it('correctly calculates httpLabel for HTTPS protocol', () => { createComponent({ httpLink: httpsLink }); - expect(wrapper.find(GlNewDropdownHeader).text()).toContain('HTTPS'); + expect(wrapper.find(GlDropdownSectionHeader).text()).toContain('HTTPS'); }); }); }); diff --git a/spec/frontend/vue_shared/components/commit_spec.js b/spec/frontend/vue_shared/components/commit_spec.js index 3510c9b699d..9b5c0941a0d 100644 --- a/spec/frontend/vue_shared/components/commit_spec.js +++ b/spec/frontend/vue_shared/components/commit_spec.js @@ -1,6 +1,6 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import CommitComponent from '~/vue_shared/components/commit.vue'; -import Icon from '~/vue_shared/components/icon.vue'; import UserAvatarLink from '~/vue_shared/components/user_avatar/user_avatar_link.vue'; describe('Commit component', () => { @@ -8,7 +8,7 @@ describe('Commit component', () => { let wrapper; const findIcon = name => { - const icons = wrapper.findAll(Icon).filter(c => c.attributes('name') === name); + const icons = wrapper.findAll(GlIcon).filter(c => c.attributes('name') === name); return icons.length ? icons.at(0) : icons; }; @@ -46,7 +46,7 @@ describe('Commit component', () => { expect( wrapper .find('.icon-container') - .find(Icon) + .find(GlIcon) .exists(), ).toBe(true); }); diff --git a/spec/frontend/vue_shared/components/confirm_modal_spec.js b/spec/frontend/vue_shared/components/confirm_modal_spec.js index 7bccd6f1a64..5d92af64de0 100644 --- a/spec/frontend/vue_shared/components/confirm_modal_spec.js +++ b/spec/frontend/vue_shared/components/confirm_modal_spec.js @@ -1,5 +1,4 @@ import { shallowMount } from '@vue/test-utils'; -import { GlModal } from '@gitlab/ui'; import { TEST_HOST } from 'helpers/test_constants'; import ConfirmModal from '~/vue_shared/components/confirm_modal.vue'; @@ -21,9 +20,14 @@ describe('vue_shared/components/confirm_modal', () => { selector: '.test-button', }; - const actionSpies = { - openModal: jest.fn(), - closeModal: jest.fn(), + const popupMethods = { + hide: jest.fn(), + show: jest.fn(), + }; + + const GlModalStub = { + template: '<div><slot></slot></div>', + methods: popupMethods, }; let wrapper; @@ -34,8 +38,8 @@ describe('vue_shared/components/confirm_modal', () => { ...defaultProps, ...props, }, - methods: { - ...actionSpies, + stubs: { + GlModal: GlModalStub, }, }); }; @@ -44,7 +48,7 @@ describe('vue_shared/components/confirm_modal', () => { wrapper.destroy(); }); - const findModal = () => wrapper.find(GlModal); + const findModal = () => wrapper.find(GlModalStub); const findForm = () => wrapper.find('form'); const findFormData = () => findForm() @@ -103,7 +107,7 @@ describe('vue_shared/components/confirm_modal', () => { }); it('does not close modal', () => { - expect(actionSpies.closeModal).not.toHaveBeenCalled(); + expect(popupMethods.hide).not.toHaveBeenCalled(); }); describe('when modal closed', () => { @@ -112,7 +116,7 @@ describe('vue_shared/components/confirm_modal', () => { }); it('closes modal', () => { - expect(actionSpies.closeModal).toHaveBeenCalled(); + expect(popupMethods.hide).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js index 223e22d650b..afd1f1a3123 100644 --- a/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js +++ b/spec/frontend/vue_shared/components/date_time_picker/date_time_picker_spec.js @@ -234,7 +234,8 @@ describe('DateTimePicker', () => { }); it('unchecks quick range when text is input is clicked', () => { - const findActiveItems = () => findQuickRangeItems().filter(w => w.is('.active')); + const findActiveItems = () => + findQuickRangeItems().filter(w => w.classes().includes('active')); expect(findActiveItems().length).toBe(1); @@ -332,13 +333,13 @@ describe('DateTimePicker', () => { expect(items.length).toBe(Object.keys(otherTimeRanges).length); expect(items.at(0).text()).toBe('1 minute'); - expect(items.at(0).is('.active')).toBe(false); + expect(items.at(0).classes()).not.toContain('active'); expect(items.at(1).text()).toBe('2 minutes'); - expect(items.at(1).is('.active')).toBe(true); + expect(items.at(1).classes()).toContain('active'); expect(items.at(2).text()).toBe('5 minutes'); - expect(items.at(2).is('.active')).toBe(false); + expect(items.at(2).classes()).not.toContain('active'); }); }); diff --git a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js index e0e982f4e11..e91e6577aaf 100644 --- a/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js +++ b/spec/frontend/vue_shared/components/diff_viewer/viewers/renamed_spec.js @@ -14,19 +14,13 @@ import { const localVue = createLocalVue(); localVue.use(Vuex); -function createRenamedComponent({ - props = {}, - methods = {}, - store = new Vuex.Store({}), - deep = false, -}) { +function createRenamedComponent({ props = {}, store = new Vuex.Store({}), deep = false }) { const mnt = deep ? mount : shallowMount; return mnt(Renamed, { propsData: { ...props }, localVue, store, - methods, }); } @@ -258,25 +252,17 @@ describe('Renamed Diff Viewer', () => { 'includes a link to the full file for alternate viewer type "$altType"', ({ altType, linkText }) => { const file = { ...diffFile }; - const clickMock = jest.fn().mockImplementation(() => {}); file.alternate_viewer.name = altType; wrapper = createRenamedComponent({ deep: true, props: { diffFile: file }, - methods: { - clickLink: clickMock, - }, }); const link = wrapper.find('a'); expect(link.text()).toEqual(linkText); expect(link.attributes('href')).toEqual(DIFF_FILE_VIEW_PATH); - - link.vm.$emit('click'); - - expect(clickMock).toHaveBeenCalled(); }, ); }); diff --git a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js index ffdeb25439c..efa30bf6605 100644 --- a/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js +++ b/spec/frontend/vue_shared/components/dropdown/dropdown_search_input_spec.js @@ -32,12 +32,6 @@ describe('DropdownSearchInputComponent', () => { expect(wrapper.find('.fa-search.dropdown-input-search').exists()).toBe(true); }); - it('renders clear search icon element', () => { - expect(wrapper.find('.fa-times.dropdown-input-clear.js-dropdown-input-clear').exists()).toBe( - true, - ); - }); - it('displays custom placeholder text', () => { expect(findInputEl().attributes('placeholder')).toBe(defaultProps.placeholderText); }); diff --git a/spec/frontend/vue_shared/components/file_finder/index_spec.js b/spec/frontend/vue_shared/components/file_finder/index_spec.js index f9e56774526..40026021777 100644 --- a/spec/frontend/vue_shared/components/file_finder/index_spec.js +++ b/spec/frontend/vue_shared/components/file_finder/index_spec.js @@ -84,7 +84,7 @@ describe('File finder item spec', () => { waitForPromises() .then(() => { - vm.$el.querySelector('.dropdown-input-clear').click(); + vm.clearSearchInput(); }) .then(waitForPromises) .then(() => { @@ -94,13 +94,13 @@ describe('File finder item spec', () => { .catch(done.fail); }); - it('clear button focues search input', done => { + it('clear button focuses search input', done => { jest.spyOn(vm.$refs.searchInput, 'focus').mockImplementation(() => {}); vm.searchText = 'index'; waitForPromises() .then(() => { - vm.$el.querySelector('.dropdown-input-clear').click(); + vm.clearSearchInput(); }) .then(waitForPromises) .then(() => { @@ -319,8 +319,8 @@ describe('File finder item spec', () => { .catch(done.fail); }); - it('calls toggle on `command+p` key press', done => { - Mousetrap.trigger('command+p'); + it('calls toggle on `mod+p` key press', done => { + Mousetrap.trigger('mod+p'); vm.$nextTick() .then(() => { @@ -330,39 +330,28 @@ describe('File finder item spec', () => { .catch(done.fail); }); - it('calls toggle on `ctrl+p` key press', done => { - Mousetrap.trigger('ctrl+p'); - - vm.$nextTick() - .then(() => { - expect(vm.toggle).toHaveBeenCalled(); - }) - .then(done) - .catch(done.fail); - }); - - it('always allows `command+p` to trigger toggle', () => { + it('always allows `mod+p` to trigger toggle', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'command+p'), - ).toBe(false); - }); - - it('always allows `ctrl+p` to trigger toggle', () => { - expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 'ctrl+p'), + Mousetrap.prototype.stopCallback( + null, + vm.$el.querySelector('.dropdown-input-field'), + 'mod+p', + ), ).toBe(false); }); it('onlys handles `t` when focused in input-field', () => { expect( - vm.mousetrapStopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), + Mousetrap.prototype.stopCallback(null, vm.$el.querySelector('.dropdown-input-field'), 't'), ).toBe(true); }); it('stops callback in monaco editor', () => { setFixtures('<div class="inputarea"></div>'); - expect(vm.mousetrapStopCallback(null, document.querySelector('.inputarea'), 't')).toBe(true); + expect( + Mousetrap.prototype.stopCallback(null, document.querySelector('.inputarea'), 't'), + ).toBe(true); }); }); }); diff --git a/spec/frontend/vue_shared/components/file_row_spec.js b/spec/frontend/vue_shared/components/file_row_spec.js index 1acd2e05464..d28c35d26bf 100644 --- a/spec/frontend/vue_shared/components/file_row_spec.js +++ b/spec/frontend/vue_shared/components/file_row_spec.js @@ -118,7 +118,7 @@ describe('File row component', () => { level: 0, }); - expect(wrapper.contains(FileHeader)).toBe(true); + expect(wrapper.find(FileHeader).exists()).toBe(true); }); it('matches the current route against encoded file URL', () => { @@ -139,4 +139,16 @@ describe('File row component', () => { expect(wrapper.vm.hasUrlAtCurrentRoute()).toBe(true); }); + + it('render with the correct file classes prop', () => { + createComponent({ + file: { + ...file(), + }, + level: 0, + fileClasses: 'font-weight-bold', + }); + + expect(wrapper.find('.file-row-name').classes()).toContain('font-weight-bold'); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js index 73dbecadd89..c79880d4766 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_bar_root_spec.js @@ -1,19 +1,28 @@ import { shallowMount, mount } from '@vue/test-utils'; -import { - GlFilteredSearch, - GlButtonGroup, - GlButton, - GlNewDropdown as GlDropdown, - GlNewDropdownItem as GlDropdownItem, -} from '@gitlab/ui'; +import { GlFilteredSearch, GlButtonGroup, GlButton, GlDropdown, GlDropdownItem } from '@gitlab/ui'; import FilteredSearchBarRoot from '~/vue_shared/components/filtered_search_bar/filtered_search_bar_root.vue'; +import { uniqueTokens } from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; import { SortDirection } from '~/vue_shared/components/filtered_search_bar/constants'; import RecentSearchesStore from '~/filtered_search/stores/recent_searches_store'; import RecentSearchesService from '~/filtered_search/services/recent_searches_service'; -import { mockAvailableTokens, mockSortOptions, mockHistoryItems } from './mock_data'; +import { + mockAvailableTokens, + mockSortOptions, + mockHistoryItems, + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, +} from './mock_data'; + +jest.mock('~/vue_shared/components/filtered_search_bar/filtered_search_utils', () => ({ + uniqueTokens: jest.fn().mockImplementation(tokens => tokens), + stripQuotes: jest.requireActual( + '~/vue_shared/components/filtered_search_bar/filtered_search_utils', + ).stripQuotes, +})); const createComponent = ({ shallow = true, @@ -52,10 +61,10 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.filterValue).toEqual([]); expect(wrapper.vm.selectedSortOption).toBe(mockSortOptions[0].sortDirection.descending); expect(wrapper.vm.selectedSortDirection).toBe(SortDirection.descending); - expect(wrapper.contains(GlButtonGroup)).toBe(true); - expect(wrapper.contains(GlButton)).toBe(true); - expect(wrapper.contains(GlDropdown)).toBe(true); - expect(wrapper.contains(GlDropdownItem)).toBe(true); + expect(wrapper.find(GlButtonGroup).exists()).toBe(true); + expect(wrapper.find(GlButton).exists()).toBe(true); + expect(wrapper.find(GlDropdown).exists()).toBe(true); + expect(wrapper.find(GlDropdownItem).exists()).toBe(true); }); it('does not initialize `selectedSortOption` and `selectedSortDirection` when `sortOptions` is not applied and hides the sort dropdown', () => { @@ -63,23 +72,31 @@ describe('FilteredSearchBarRoot', () => { expect(wrapperNoSort.vm.filterValue).toEqual([]); expect(wrapperNoSort.vm.selectedSortOption).toBe(undefined); - expect(wrapperNoSort.contains(GlButtonGroup)).toBe(false); - expect(wrapperNoSort.contains(GlButton)).toBe(false); - expect(wrapperNoSort.contains(GlDropdown)).toBe(false); - expect(wrapperNoSort.contains(GlDropdownItem)).toBe(false); + expect(wrapperNoSort.find(GlButtonGroup).exists()).toBe(false); + expect(wrapperNoSort.find(GlButton).exists()).toBe(false); + expect(wrapperNoSort.find(GlDropdown).exists()).toBe(false); + expect(wrapperNoSort.find(GlDropdownItem).exists()).toBe(false); }); }); describe('computed', () => { describe('tokenSymbols', () => { it('returns a map containing type and symbols from `tokens` prop', () => { - expect(wrapper.vm.tokenSymbols).toEqual({ author_username: '@', label_name: '~' }); + expect(wrapper.vm.tokenSymbols).toEqual({ + author_username: '@', + label_name: '~', + milestone_title: '%', + }); }); }); describe('tokenTitles', () => { it('returns a map containing type and title from `tokens` prop', () => { - expect(wrapper.vm.tokenTitles).toEqual({ author_username: 'Author', label_name: 'Label' }); + expect(wrapper.vm.tokenTitles).toEqual({ + author_username: 'Author', + label_name: 'Label', + milestone_title: 'Milestone', + }); }); }); @@ -131,6 +148,20 @@ describe('FilteredSearchBarRoot', () => { expect(wrapper.vm.filteredRecentSearches[0]).toEqual({ foo: 'bar' }); }); + it('returns array of recent searches sanitizing any duplicate token values', async () => { + wrapper.setData({ + recentSearches: [ + [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValueLabel], + [tokenValueAuthor, tokenValueMilestone], + ], + }); + + await wrapper.vm.$nextTick(); + + expect(wrapper.vm.filteredRecentSearches).toHaveLength(2); + expect(uniqueTokens).toHaveBeenCalled(); + }); + it('returns undefined when recentSearchesStorageKey prop is not set on component', async () => { wrapper.setProps({ recentSearchesStorageKey: '', @@ -182,40 +213,12 @@ describe('FilteredSearchBarRoot', () => { }); describe('removeQuotesEnclosure', () => { - const mockFilters = [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: '"Documentation Update"', - operator: '=', - }, - }, - 'foo', - ]; + const mockFilters = [tokenValueAuthor, tokenValueLabel, 'foo']; it('returns filter array with unescaped strings for values which have spaces', () => { expect(wrapper.vm.removeQuotesEnclosure(mockFilters)).toEqual([ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: 'Documentation Update', - operator: '=', - }, - }, + tokenValueAuthor, + tokenValueLabel, 'foo', ]); }); @@ -277,21 +280,26 @@ describe('FilteredSearchBarRoot', () => { }); describe('handleFilterSubmit', () => { - const mockFilters = [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - 'foo', - ]; + const mockFilters = [tokenValueAuthor, 'foo']; + + beforeEach(async () => { + wrapper.setData({ + filterValue: mockFilters, + }); + + await wrapper.vm.$nextTick(); + }); + + it('calls `uniqueTokens` on `filterValue` prop to remove duplicates', () => { + wrapper.vm.handleFilterSubmit(); + + expect(uniqueTokens).toHaveBeenCalledWith(wrapper.vm.filterValue); + }); it('calls `recentSearchesStore.addRecentSearch` with serialized value of provided `filters` param', () => { jest.spyOn(wrapper.vm.recentSearchesStore, 'addRecentSearch'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearchesStore.addRecentSearch).toHaveBeenCalledWith(mockFilters); @@ -301,7 +309,7 @@ describe('FilteredSearchBarRoot', () => { it('calls `recentSearchesService.save` with array of searches', () => { jest.spyOn(wrapper.vm.recentSearchesService, 'save'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearchesService.save).toHaveBeenCalledWith([mockFilters]); @@ -311,7 +319,7 @@ describe('FilteredSearchBarRoot', () => { it('sets `recentSearches` data prop with array of searches', () => { jest.spyOn(wrapper.vm.recentSearchesService, 'save'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); return wrapper.vm.recentSearchesPromise.then(() => { expect(wrapper.vm.recentSearches).toEqual([mockFilters]); @@ -329,7 +337,7 @@ describe('FilteredSearchBarRoot', () => { it('emits component event `onFilter` with provided filters param', () => { jest.spyOn(wrapper.vm, 'removeQuotesEnclosure'); - wrapper.vm.handleFilterSubmit(mockFilters); + wrapper.vm.handleFilterSubmit(); expect(wrapper.emitted('onFilter')[0]).toEqual([mockFilters]); expect(wrapper.vm.removeQuotesEnclosure).toHaveBeenCalledWith(mockFilters); @@ -366,7 +374,9 @@ describe('FilteredSearchBarRoot', () => { '.gl-search-box-by-click-menu .gl-search-box-by-click-history-item', ); - expect(searchHistoryItemsEl.at(0).text()).toBe('Author := @tobyLabel := ~Bug"duo"'); + expect(searchHistoryItemsEl.at(0).text()).toBe( + 'Author := @rootLabel := ~bugMilestone := %v1.0"duo"', + ); wrapperFullMount.destroy(); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js index a857f84adf1..4869e75a2f3 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/filtered_search_utils_spec.js @@ -1,4 +1,18 @@ -import * as filteredSearchUtils from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; +import { + stripQuotes, + uniqueTokens, + prepareTokens, + processFilters, + filterToQueryObject, + urlQueryToFilter, +} from '~/vue_shared/components/filtered_search_bar/filtered_search_utils'; + +import { + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValuePlain, +} from './mock_data'; describe('Filtered Search Utils', () => { describe('stripQuotes', () => { @@ -9,11 +23,196 @@ describe('Filtered Search Utils', () => { ${'FooBar'} | ${'FooBar'} ${"Foo'Bar"} | ${"Foo'Bar"} ${'Foo"Bar'} | ${'Foo"Bar'} + ${'Foo Bar'} | ${'Foo Bar'} `( 'returns string $outputValue when called with string $inputValue', ({ inputValue, outputValue }) => { - expect(filteredSearchUtils.stripQuotes(inputValue)).toBe(outputValue); + expect(stripQuotes(inputValue)).toBe(outputValue); }, ); }); + + describe('uniqueTokens', () => { + it('returns tokens array with duplicates removed', () => { + expect( + uniqueTokens([ + tokenValueAuthor, + tokenValueLabel, + tokenValueMilestone, + tokenValueLabel, + tokenValuePlain, + ]), + ).toHaveLength(4); // Removes 2nd instance of tokenValueLabel + }); + + it('returns tokens array as it is if it does not have duplicates', () => { + expect( + uniqueTokens([tokenValueAuthor, tokenValueLabel, tokenValueMilestone, tokenValuePlain]), + ).toHaveLength(4); + }); + }); +}); + +describe('prepareTokens', () => { + describe('with empty data', () => { + it('returns an empty array', () => { + expect(prepareTokens()).toEqual([]); + expect(prepareTokens({})).toEqual([]); + expect(prepareTokens({ milestone: null, author: null, assignees: [], labels: [] })).toEqual( + [], + ); + }); + }); + + it.each([ + [ + 'milestone', + { value: 'v1.0', operator: '=' }, + [{ type: 'milestone', value: { data: 'v1.0', operator: '=' } }], + ], + [ + 'author', + { value: 'mr.popo', operator: '!=' }, + [{ type: 'author', value: { data: 'mr.popo', operator: '!=' } }], + ], + [ + 'labels', + [{ value: 'z-fighters', operator: '=' }], + [{ type: 'labels', value: { data: 'z-fighters', operator: '=' } }], + ], + [ + 'assignees', + [{ value: 'krillin', operator: '=' }, { value: 'piccolo', operator: '!=' }], + [ + { type: 'assignees', value: { data: 'krillin', operator: '=' } }, + { type: 'assignees', value: { data: 'piccolo', operator: '!=' } }, + ], + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }], + [ + { type: 'foo', value: { data: 'bar', operator: '!=' } }, + { type: 'foo', value: { data: 'baz', operator: '!=' } }, + ], + ], + ])('gathers %s=%j into result=%j', (token, value, result) => { + const res = prepareTokens({ [token]: value }); + expect(res).toEqual(result); + }); +}); + +describe('processFilters', () => { + it('processes multiple filter values', () => { + const result = processFilters([ + { type: 'foo', value: { data: 'foo', operator: '=' } }, + { type: 'bar', value: { data: 'bar1', operator: '=' } }, + { type: 'bar', value: { data: 'bar2', operator: '!=' } }, + ]); + + expect(result).toStrictEqual({ + foo: [{ value: 'foo', operator: '=' }], + bar: [{ value: 'bar1', operator: '=' }, { value: 'bar2', operator: '!=' }], + }); + }); + + it('does not remove wrapping double quotes from the data', () => { + const result = processFilters([ + { type: 'foo', value: { data: '"value with spaces"', operator: '=' } }, + ]); + + expect(result).toStrictEqual({ + foo: [{ value: '"value with spaces"', operator: '=' }], + }); + }); +}); + +describe('filterToQueryObject', () => { + describe('with empty data', () => { + it('returns an empty object', () => { + expect(filterToQueryObject()).toEqual({}); + expect(filterToQueryObject({})).toEqual({}); + expect(filterToQueryObject({ author_username: null, label_name: [] })).toEqual({ + author_username: null, + label_name: null, + 'not[author_username]': null, + 'not[label_name]': null, + }); + }); + }); + + it.each([ + [ + 'author_username', + { value: 'v1.0', operator: '=' }, + { author_username: 'v1.0', 'not[author_username]': null }, + ], + [ + 'author_username', + { value: 'v1.0', operator: '!=' }, + { author_username: null, 'not[author_username]': 'v1.0' }, + ], + [ + 'label_name', + [{ value: 'z-fighters', operator: '=' }], + { label_name: ['z-fighters'], 'not[label_name]': null }, + ], + [ + 'label_name', + [{ value: 'z-fighters', operator: '!=' }], + { label_name: null, 'not[label_name]': ['z-fighters'] }, + ], + [ + 'foo', + [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }], + { foo: ['bar', 'baz'], 'not[foo]': null }, + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }], + { foo: null, 'not[foo]': ['bar', 'baz'] }, + ], + [ + 'foo', + [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '=' }], + { foo: ['baz'], 'not[foo]': ['bar'] }, + ], + ])('gathers filter values %s=%j into query object=%j', (token, value, result) => { + const res = filterToQueryObject({ [token]: value }); + expect(res).toEqual(result); + }); +}); + +describe('urlQueryToFilter', () => { + describe('with empty data', () => { + it('returns an empty object', () => { + expect(urlQueryToFilter()).toEqual({}); + expect(urlQueryToFilter('')).toEqual({}); + expect(urlQueryToFilter('author_username=&milestone_title=&')).toEqual({}); + }); + }); + + it.each([ + ['author_username=v1.0', { author_username: { value: 'v1.0', operator: '=' } }], + ['not[author_username]=v1.0', { author_username: { value: 'v1.0', operator: '!=' } }], + ['foo=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }], + ['foo=bar&foo[]=baz', { foo: [{ value: 'baz', operator: '=' }] }], + ['not[foo]=bar&foo=baz', { foo: { value: 'baz', operator: '=' } }], + [ + 'foo[]=bar&foo[]=baz¬[foo]=', + { foo: [{ value: 'bar', operator: '=' }, { value: 'baz', operator: '=' }] }, + ], + [ + 'foo[]=¬[foo][]=bar¬[foo][]=baz', + { foo: [{ value: 'bar', operator: '!=' }, { value: 'baz', operator: '!=' }] }, + ], + [ + 'foo[]=baz¬[foo][]=bar', + { foo: [{ value: 'baz', operator: '=' }, { value: 'bar', operator: '!=' }] }, + ], + ['not[foo][]=bar', { foo: [{ value: 'bar', operator: '!=' }] }], + ])('gathers filter values %s into query object=%j', (query, result) => { + const res = urlQueryToFilter(query); + expect(res).toEqual(result); + }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js index dcccb1f49b6..e0a3208cac9 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/mock_data.js @@ -1,6 +1,7 @@ import { mockLabels } from 'jest/vue_shared/components/sidebar/labels_select_vue/mock_data'; import Api from '~/api'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; +import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; @@ -33,6 +34,8 @@ export const mockAuthor3 = { export const mockAuthors = [mockAuthor1, mockAuthor2, mockAuthor3]; +export const mockBranches = [{ name: 'Master' }, { name: 'v1.x' }, { name: 'my-Branch' }]; + export const mockRegularMilestone = { id: 1, name: '4.0', @@ -55,6 +58,16 @@ export const mockMilestones = [ mockEscapedMilestone, ]; +export const mockBranchToken = { + type: 'source_branch', + icon: 'branch', + title: 'Source Branch', + unique: true, + token: BranchToken, + operators: [{ value: '=', description: 'is', default: 'true' }], + fetchBranches: Api.branches.bind(Api), +}; + export const mockAuthorToken = { type: 'author_username', icon: 'user', @@ -89,36 +102,40 @@ export const mockMilestoneToken = { fetchMilestones: () => Promise.resolve({ data: mockMilestones }), }; -export const mockAvailableTokens = [mockAuthorToken, mockLabelToken]; +export const mockAvailableTokens = [mockAuthorToken, mockLabelToken, mockMilestoneToken]; + +export const tokenValueAuthor = { + type: 'author_username', + value: { + data: 'root', + operator: '=', + }, +}; + +export const tokenValueLabel = { + type: 'label_name', + value: { + operator: '=', + data: 'bug', + }, +}; + +export const tokenValueMilestone = { + type: 'milestone_title', + value: { + operator: '=', + data: 'v1.0', + }, +}; + +export const tokenValuePlain = { + type: 'filtered-search-term', + value: { data: 'foo' }, +}; export const mockHistoryItems = [ - [ - { - type: 'author_username', - value: { - data: 'toby', - operator: '=', - }, - }, - { - type: 'label_name', - value: { - data: 'Bug', - operator: '=', - }, - }, - 'duo', - ], - [ - { - type: 'author_username', - value: { - data: 'root', - operator: '=', - }, - }, - 'si', - ], + [tokenValueAuthor, tokenValueLabel, tokenValueMilestone, 'duo'], + [tokenValueAuthor, 'si'], ]; export const mockSortOptions = [ diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js index 160febf9d06..72840ce381f 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/author_token_spec.js @@ -1,18 +1,42 @@ import { mount } from '@vue/test-utils'; -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchTokenSegment, + GlFilteredSearchSuggestion, + GlDropdownDivider, +} from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; import AuthorToken from '~/vue_shared/components/filtered_search_bar/tokens/author_token.vue'; import { mockAuthorToken, mockAuthors } from '../mock_data'; jest.mock('~/flash'); - -const createComponent = ({ config = mockAuthorToken, value = { data: '' }, active = false } = {}) => - mount(AuthorToken, { +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockAuthorToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(AuthorToken, { propsData: { config, value, @@ -22,18 +46,9 @@ const createComponent = ({ config = mockAuthorToken, value = { data: '' }, activ portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, }, - stubs: { - Portal: { - template: '<div><slot></slot></div>', - }, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, - }, + stubs, }); +} describe('AuthorToken', () => { let mock; @@ -141,5 +156,57 @@ describe('AuthorToken', () => { expect(tokenSegments.at(2).text()).toBe(mockAuthors[0].name); // "Administrator" }); }); + + it('renders provided defaultAuthors as suggestions', async () => { + const defaultAuthors = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + wrapper = createComponent({ + active: true, + config: { ...mockAuthorToken, defaultAuthors }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultAuthors.length); + defaultAuthors.forEach((label, index) => { + expect(suggestions.at(index).text()).toBe(label.text); + }); + }); + + it('does not render divider when no defaultAuthors', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockAuthorToken, defaultAuthors: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders `DEFAULT_LABEL_ANY` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockAuthorToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(1); + expect(suggestions.at(0).text()).toBe(DEFAULT_LABEL_ANY.text); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js new file mode 100644 index 00000000000..12b7fd58670 --- /dev/null +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/branch_token_spec.js @@ -0,0 +1,207 @@ +import { mount } from '@vue/test-utils'; +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; +import MockAdapter from 'axios-mock-adapter'; +import waitForPromises from 'helpers/wait_for_promises'; + +import axios from '~/lib/utils/axios_utils'; +import createFlash from '~/flash'; +import { + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; +import BranchToken from '~/vue_shared/components/filtered_search_bar/tokens/branch_token.vue'; + +import { mockBranches, mockBranchToken } from '../mock_data'; + +jest.mock('~/flash'); +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockBranchToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(BranchToken, { + propsData: { + config, + value, + active, + }, + provide: { + portalName: 'fake target', + alignSuggestions: function fakeAlignSuggestions() {}, + }, + stubs, + }); +} + +describe('BranchToken', () => { + let mock; + let wrapper; + + beforeEach(() => { + mock = new MockAdapter(axios); + }); + + afterEach(() => { + mock.restore(); + wrapper.destroy(); + }); + + describe('computed', () => { + beforeEach(async () => { + wrapper = createComponent({ value: { data: mockBranches[0].name } }); + + wrapper.setData({ + branches: mockBranches, + }); + + await wrapper.vm.$nextTick(); + }); + + describe('currentValue', () => { + it('returns lowercase string for `value.data`', () => { + expect(wrapper.vm.currentValue).toBe('master'); + }); + }); + + describe('activeBranch', () => { + it('returns object for currently present `value.data`', () => { + expect(wrapper.vm.activeBranch).toEqual(mockBranches[0]); + }); + }); + }); + + describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + + describe('fetchBranchBySearchTerm', () => { + it('calls `config.fetchBranches` with provided searchTerm param', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches'); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + expect(wrapper.vm.config.fetchBranches).toHaveBeenCalledWith('foo'); + }); + + it('sets response to `branches` when request is succesful', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches').mockResolvedValue({ data: mockBranches }); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.branches).toEqual(mockBranches); + }); + }); + + it('calls `createFlash` with flash error message when request fails', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(createFlash).toHaveBeenCalledWith({ + message: 'There was a problem fetching branches.', + }); + }); + }); + + it('sets `loading` to false when request completes', () => { + jest.spyOn(wrapper.vm.config, 'fetchBranches').mockRejectedValue({}); + + wrapper.vm.fetchBranchBySearchTerm('foo'); + + return waitForPromises().then(() => { + expect(wrapper.vm.loading).toBe(false); + }); + }); + }); + }); + + describe('template', () => { + const defaultBranches = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + async function showSuggestions() { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + } + + beforeEach(async () => { + wrapper = createComponent({ value: { data: mockBranches[0].name } }); + + wrapper.setData({ + branches: mockBranches, + }); + + await wrapper.vm.$nextTick(); + }); + + it('renders gl-filtered-search-token component', () => { + expect(wrapper.find(GlFilteredSearchToken).exists()).toBe(true); + }); + + it('renders token item when value is selected', () => { + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + + expect(tokenSegments).toHaveLength(3); + expect(tokenSegments.at(2).text()).toBe(mockBranches[0].name); + }); + + it('renders provided defaultBranches as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockBranchToken, defaultBranches }, + stubs: { Portal: true }, + }); + await showSuggestions(); + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultBranches.length); + defaultBranches.forEach((branch, index) => { + expect(suggestions.at(index).text()).toBe(branch.text); + }); + }); + + it('does not render divider when no defaultBranches', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockBranchToken, defaultBranches: [] }, + stubs: { Portal: true }, + }); + await showSuggestions(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders no suggestions as default', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockBranchToken }, + stubs: { Portal: true }, + }); + await showSuggestions(); + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(0); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js index 0e60ee99327..3feb05bab35 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/label_token_spec.js @@ -1,5 +1,10 @@ import { mount } from '@vue/test-utils'; -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import { @@ -9,14 +14,34 @@ import { import axios from '~/lib/utils/axios_utils'; import { deprecatedCreateFlash as createFlash } from '~/flash'; +import { + DEFAULT_LABELS, + DEFAULT_LABEL_NONE, + DEFAULT_LABEL_ANY, +} from '~/vue_shared/components/filtered_search_bar/constants'; import LabelToken from '~/vue_shared/components/filtered_search_bar/tokens/label_token.vue'; import { mockLabelToken } from '../mock_data'; jest.mock('~/flash'); - -const createComponent = ({ config = mockLabelToken, value = { data: '' }, active = false } = {}) => - mount(LabelToken, { +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockLabelToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(LabelToken, { propsData: { config, value, @@ -26,18 +51,9 @@ const createComponent = ({ config = mockLabelToken, value = { data: '' }, active portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, }, - stubs: { - Portal: { - template: '<div><slot></slot></div>', - }, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, - }, + stubs, }); +} describe('LabelToken', () => { let mock; @@ -45,7 +61,6 @@ describe('LabelToken', () => { beforeEach(() => { mock = new MockAdapter(axios); - wrapper = createComponent(); }); afterEach(() => { @@ -98,6 +113,10 @@ describe('LabelToken', () => { }); describe('methods', () => { + beforeEach(() => { + wrapper = createComponent(); + }); + describe('fetchLabelBySearchTerm', () => { it('calls `config.fetchLabels` with provided searchTerm param', () => { jest.spyOn(wrapper.vm.config, 'fetchLabels'); @@ -140,6 +159,8 @@ describe('LabelToken', () => { }); describe('template', () => { + const defaultLabels = [DEFAULT_LABEL_NONE, DEFAULT_LABEL_ANY]; + beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularLabel.title}"` } }); @@ -166,5 +187,58 @@ describe('LabelToken', () => { .attributes('style'), ).toBe('background-color: rgb(186, 218, 85); color: rgb(255, 255, 255);'); }); + + it('renders provided defaultLabels as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockLabelToken, defaultLabels }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultLabels.length); + defaultLabels.forEach((label, index) => { + expect(suggestions.at(index).text()).toBe(label.text); + }); + }); + + it('does not render divider when no defaultLabels', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockLabelToken, defaultLabels: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders `DEFAULT_LABELS` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockLabelToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_LABELS.length); + DEFAULT_LABELS.forEach((label, index) => { + expect(suggestions.at(index).text()).toBe(label.text); + }); + }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js index de893bf44c8..0ec814e3f15 100644 --- a/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js +++ b/spec/frontend/vue_shared/components/filtered_search_bar/tokens/milestone_token_spec.js @@ -1,10 +1,16 @@ import { mount } from '@vue/test-utils'; -import { GlFilteredSearchToken, GlFilteredSearchTokenSegment } from '@gitlab/ui'; +import { + GlFilteredSearchToken, + GlFilteredSearchSuggestion, + GlFilteredSearchTokenSegment, + GlDropdownDivider, +} from '@gitlab/ui'; import MockAdapter from 'axios-mock-adapter'; import waitForPromises from 'helpers/wait_for_promises'; import axios from '~/lib/utils/axios_utils'; import createFlash from '~/flash'; +import { DEFAULT_MILESTONES } from '~/vue_shared/components/filtered_search_bar/constants'; import MilestoneToken from '~/vue_shared/components/filtered_search_bar/tokens/milestone_token.vue'; import { @@ -16,12 +22,24 @@ import { jest.mock('~/flash'); -const createComponent = ({ - config = mockMilestoneToken, - value = { data: '' }, - active = false, -} = {}) => - mount(MilestoneToken, { +const defaultStubs = { + Portal: true, + GlFilteredSearchSuggestionList: { + template: '<div></div>', + methods: { + getValue: () => '=', + }, + }, +}; + +function createComponent(options = {}) { + const { + config = mockMilestoneToken, + value = { data: '' }, + active = false, + stubs = defaultStubs, + } = options; + return mount(MilestoneToken, { propsData: { config, value, @@ -31,18 +49,9 @@ const createComponent = ({ portalName: 'fake target', alignSuggestions: function fakeAlignSuggestions() {}, }, - stubs: { - Portal: { - template: '<div><slot></slot></div>', - }, - GlFilteredSearchSuggestionList: { - template: '<div></div>', - methods: { - getValue: () => '=', - }, - }, - }, + stubs, }); +} describe('MilestoneToken', () => { let mock; @@ -128,6 +137,8 @@ describe('MilestoneToken', () => { }); describe('template', () => { + const defaultMilestones = [{ text: 'foo', value: 'foo' }, { text: 'bar', value: 'baz' }]; + beforeEach(async () => { wrapper = createComponent({ value: { data: `"${mockRegularMilestone.title}"` } }); @@ -146,7 +157,60 @@ describe('MilestoneToken', () => { const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); expect(tokenSegments).toHaveLength(3); // Milestone, =, '%"4.0"' - expect(tokenSegments.at(2).text()).toBe(`%"${mockRegularMilestone.title}"`); // "4.0 RC1" + expect(tokenSegments.at(2).text()).toBe(`%${mockRegularMilestone.title}`); // "4.0 RC1" + }); + + it('renders provided defaultMilestones as suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken, defaultMilestones }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(defaultMilestones.length); + defaultMilestones.forEach((milestone, index) => { + expect(suggestions.at(index).text()).toBe(milestone.text); + }); + }); + + it('does not render divider when no defaultMilestones', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken, defaultMilestones: [] }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + expect(wrapper.contains(GlFilteredSearchSuggestion)).toBe(false); + expect(wrapper.contains(GlDropdownDivider)).toBe(false); + }); + + it('renders `DEFAULT_MILESTONES` as default suggestions', async () => { + wrapper = createComponent({ + active: true, + config: { ...mockMilestoneToken }, + stubs: { Portal: true }, + }); + const tokenSegments = wrapper.findAll(GlFilteredSearchTokenSegment); + const suggestionsSegment = tokenSegments.at(2); + suggestionsSegment.vm.$emit('activate'); + await wrapper.vm.$nextTick(); + + const suggestions = wrapper.findAll(GlFilteredSearchSuggestion); + + expect(suggestions).toHaveLength(DEFAULT_MILESTONES.length); + DEFAULT_MILESTONES.forEach((milestone, index) => { + expect(suggestions.at(index).text()).toBe(milestone.text); + }); }); }); }); diff --git a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js b/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js deleted file mode 100644 index 87cafa0bb8c..00000000000 --- a/spec/frontend/vue_shared/components/filtered_search_dropdown_spec.js +++ /dev/null @@ -1,190 +0,0 @@ -import Vue from 'vue'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import component from '~/vue_shared/components/filtered_search_dropdown.vue'; - -describe('Filtered search dropdown', () => { - const Component = Vue.extend(component); - let vm; - - afterEach(() => { - vm.$destroy(); - }); - - describe('with an empty array of items', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [], - filterKey: '', - }); - }); - - it('renders empty list', () => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); - }); - - it('renders filter input', () => { - expect(vm.$el.querySelector('.js-filtered-dropdown-input')).not.toBeNull(); - }); - }); - - describe('when visible numbers is less than the items length', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], - visibleItems: 2, - filterKey: 'title', - }); - }); - - it('it renders only the maximum number provided', () => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); - }); - }); - - describe('when visible number is bigger than the items length', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [{ title: 'One' }, { title: 'Two' }, { title: 'Three' }], - filterKey: 'title', - }); - }); - - it('it renders the full list of items the maximum number provided', () => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(3); - }); - }); - - describe('while filtering', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - }); - }); - - it('updates the results to match the typed value', done => { - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'three'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(2); - done(); - }); - }); - - describe('when no value matches the typed one', () => { - it('does not render any result', done => { - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'six'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - - vm.$nextTick(() => { - expect(vm.$el.querySelectorAll('.js-filtered-dropdown-result').length).toEqual(0); - done(); - }); - }); - }); - }); - - describe('with create mode enabled', () => { - describe('when there are no matches', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - showCreateMode: true, - }); - - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - }); - - it('renders a create button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button')).not.toBeNull(); - done(); - }); - }); - - it('renders computed button text', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button').textContent.trim()).toEqual( - 'Create eleven', - ); - done(); - }); - }); - - describe('on click create button', () => { - it('emits createItem event with the filter', done => { - jest.spyOn(vm, '$emit').mockImplementation(() => {}); - vm.$nextTick(() => { - vm.$el.querySelector('.js-dropdown-create-button').click(); - - expect(vm.$emit).toHaveBeenCalledWith('createItem', 'eleven'); - done(); - }); - }); - }); - }); - - describe('when there are matches', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - showCreateMode: true, - }); - - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'one'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - }); - - it('does not render a create button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); - done(); - }); - }); - }); - }); - - describe('with create mode disabled', () => { - describe('when there are no matches', () => { - beforeEach(() => { - vm = mountComponent(Component, { - items: [ - { title: 'One' }, - { title: 'Two/three' }, - { title: 'Three four' }, - { title: 'Five' }, - ], - filterKey: 'title', - }); - - vm.$el.querySelector('.js-filtered-dropdown-input').value = 'eleven'; - vm.$el.querySelector('.js-filtered-dropdown-input').dispatchEvent(new Event('input')); - }); - - it('does not render a create button', done => { - vm.$nextTick(() => { - expect(vm.$el.querySelector('.js-dropdown-create-button')).toBeNull(); - done(); - }); - }); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/icon_spec.js b/spec/frontend/vue_shared/components/icon_spec.js deleted file mode 100644 index 16728e1705a..00000000000 --- a/spec/frontend/vue_shared/components/icon_spec.js +++ /dev/null @@ -1,78 +0,0 @@ -import Vue from 'vue'; -import { mount } from '@vue/test-utils'; -import mountComponent from 'helpers/vue_mount_component_helper'; -import iconsPath from '@gitlab/svgs/dist/icons.svg'; -import Icon from '~/vue_shared/components/icon.vue'; - -jest.mock('@gitlab/svgs/dist/icons.svg', () => 'testing'); - -describe('Sprite Icon Component', () => { - describe('Initialization', () => { - let icon; - - beforeEach(() => { - const IconComponent = Vue.extend(Icon); - - icon = mountComponent(IconComponent, { - name: 'commit', - size: 32, - }); - }); - - afterEach(() => { - icon.$destroy(); - }); - - it('should return a defined Vue component', () => { - expect(icon).toBeDefined(); - }); - - it('should have <svg> as a child element', () => { - expect(icon.$el.tagName).toBe('svg'); - }); - - it('should have <use> as a child element with the correct href', () => { - expect(icon.$el.firstChild.tagName).toBe('use'); - expect(icon.$el.firstChild.getAttribute('xlink:href')).toBe(`${iconsPath}#commit`); - }); - - it('should properly compute iconSizeClass', () => { - expect(icon.iconSizeClass).toBe('s32'); - }); - - it('forbids invalid size prop', () => { - expect(icon.$options.props.size.validator(NaN)).toBeFalsy(); - expect(icon.$options.props.size.validator(0)).toBeFalsy(); - expect(icon.$options.props.size.validator(9001)).toBeFalsy(); - }); - - it('should properly render img css', () => { - const { classList } = icon.$el; - const containsSizeClass = classList.contains('s32'); - - expect(containsSizeClass).toBe(true); - }); - - it('`name` validator should return false for non existing icons', () => { - jest.spyOn(console, 'warn').mockImplementation(); - - expect(Icon.props.name.validator('non_existing_icon_sprite')).toBe(false); - }); - - it('`name` validator should return true for existing icons', () => { - expect(Icon.props.name.validator('commit')).toBe(true); - }); - }); - - it('should call registered listeners when they are triggered', () => { - const clickHandler = jest.fn(); - const wrapper = mount(Icon, { - propsData: { name: 'commit' }, - listeners: { click: clickHandler }, - }); - - wrapper.find('svg').trigger('click'); - - expect(clickHandler).toHaveBeenCalled(); - }); -}); diff --git a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js index b72f78c4f60..c87d19df1f7 100644 --- a/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js +++ b/spec/frontend/vue_shared/components/issue/issue_milestone_spec.js @@ -2,8 +2,8 @@ import Vue from 'vue'; import { shallowMount } from '@vue/test-utils'; import { mockMilestone } from 'jest/boards/mock_data'; +import { GlIcon } from '@gitlab/ui'; import IssueMilestone from '~/vue_shared/components/issue/issue_milestone.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const createComponent = (milestone = mockMilestone) => { const Component = Vue.extend(IssueMilestone); @@ -135,7 +135,7 @@ describe('IssueMilestoneComponent', () => { }); it('renders milestone icon', () => { - expect(wrapper.find(Icon).props('name')).toBe('clock'); + expect(wrapper.find(GlIcon).props('name')).toBe('clock'); }); it('renders milestone title', () => { diff --git a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js index fb9487d0bf8..2319bf61482 100644 --- a/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js +++ b/spec/frontend/vue_shared/components/issue/related_issuable_item_spec.js @@ -35,6 +35,9 @@ describe('RelatedIssuableItem', () => { weight: '<div class="js-weight-slot"></div>', }; + const findRemoveButton = () => wrapper.find({ ref: 'removeButton' }); + const findLockIcon = () => wrapper.find({ ref: 'lockIcon' }); + beforeEach(() => { mountComponent({ props, slots }); }); @@ -121,10 +124,10 @@ describe('RelatedIssuableItem', () => { }); it('renders milestone icon and name', () => { - const milestoneIcon = tokenMetadata().find('.item-milestone svg use'); + const milestoneIcon = tokenMetadata().find('.item-milestone svg'); const milestoneTitle = tokenMetadata().find('.item-milestone .milestone-title'); - expect(milestoneIcon.attributes('href')).toContain('clock'); + expect(milestoneIcon.attributes('data-testid')).toBe('clock-icon'); expect(milestoneTitle.text()).toContain('Milestone title'); }); @@ -143,25 +146,27 @@ describe('RelatedIssuableItem', () => { }); describe('remove button', () => { - const removeButton = () => wrapper.find({ ref: 'removeButton' }); - beforeEach(() => { wrapper.setProps({ canRemove: true }); }); it('renders if canRemove', () => { - expect(removeButton().exists()).toBe(true); + expect(findRemoveButton().exists()).toBe(true); + }); + + it('does not render the lock icon', () => { + expect(findLockIcon().exists()).toBe(false); }); it('renders disabled button when removeDisabled', async () => { wrapper.setData({ removeDisabled: true }); await wrapper.vm.$nextTick(); - expect(removeButton().attributes('disabled')).toEqual('disabled'); + expect(findRemoveButton().attributes('disabled')).toEqual('disabled'); }); it('triggers onRemoveRequest when clicked', async () => { - removeButton().trigger('click'); + findRemoveButton().trigger('click'); await wrapper.vm.$nextTick(); const { relatedIssueRemoveRequest } = wrapper.emitted(); @@ -169,4 +174,23 @@ describe('RelatedIssuableItem', () => { expect(relatedIssueRemoveRequest[0]).toEqual([props.idKey]); }); }); + + describe('when issue is locked', () => { + const lockedMessage = 'Issues created from a vulnerability cannot be removed'; + + beforeEach(() => { + wrapper.setProps({ + isLocked: true, + lockedMessage, + }); + }); + + it('does not render the remove button', () => { + expect(findRemoveButton().exists()).toBe(false); + }); + + it('renders the lock icon with the correct title', () => { + expect(findLockIcon().attributes('title')).toBe(lockedMessage); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/markdown/header_spec.js b/spec/frontend/vue_shared/components/markdown/header_spec.js index 551d781d296..82bc9b9fe08 100644 --- a/spec/frontend/vue_shared/components/markdown/header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/header_spec.js @@ -22,6 +22,12 @@ describe('Markdown field header component', () => { .at(0); beforeEach(() => { + window.gl = { + client: { + isMac: true, + }, + }; + createWrapper(); }); @@ -30,24 +36,40 @@ describe('Markdown field header component', () => { wrapper = null; }); - it('renders markdown header buttons', () => { - const buttons = [ - 'Add bold text', - 'Add italic text', - 'Insert a quote', - 'Insert suggestion', - 'Insert code', - 'Add a link', - 'Add a bullet list', - 'Add a numbered list', - 'Add a task list', - 'Add a table', - 'Go full screen', - ]; - const elements = findToolbarButtons(); - - elements.wrappers.forEach((buttonEl, index) => { - expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); + describe('markdown header buttons', () => { + it('renders the buttons with the correct title', () => { + const buttons = [ + 'Add bold text (⌘B)', + 'Add italic text (⌘I)', + 'Insert a quote', + 'Insert suggestion', + 'Insert code', + 'Add a link (⌘K)', + 'Add a bullet list', + 'Add a numbered list', + 'Add a task list', + 'Add a table', + 'Go full screen', + ]; + const elements = findToolbarButtons(); + + elements.wrappers.forEach((buttonEl, index) => { + expect(buttonEl.props('buttonTitle')).toBe(buttons[index]); + }); + }); + + describe('when the user is on a non-Mac', () => { + beforeEach(() => { + delete window.gl.client.isMac; + + createWrapper(); + }); + + it('renders keyboard shortcuts with Ctrl+ instead of ⌘', () => { + const boldButton = findToolbarButtonByProp('icon', 'bold'); + + expect(boldButton.props('buttonTitle')).toBe('Add bold text (Ctrl+B)'); + }); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js index c6e147899e4..a521668b15c 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_header_spec.js @@ -77,10 +77,7 @@ describe('Suggestion Diff component', () => { }); it('emits apply', () => { - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'apply', - args: [expect.any(Function)], - }); + expect(wrapper.emitted().apply).toEqual([[expect.any(Function)]]); }); it('does not render apply suggestion and add to batch buttons', () => { @@ -111,10 +108,7 @@ describe('Suggestion Diff component', () => { findAddToBatchButton().vm.$emit('click'); - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'addToBatch', - args: [], - }); + expect(wrapper.emitted().addToBatch).toEqual([[]]); }); }); @@ -124,10 +118,7 @@ describe('Suggestion Diff component', () => { findRemoveFromBatchButton().vm.$emit('click'); - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'removeFromBatch', - args: [], - }); + expect(wrapper.emitted().removeFromBatch).toEqual([[]]); }); }); @@ -137,10 +128,7 @@ describe('Suggestion Diff component', () => { findApplyBatchButton().vm.$emit('click'); - expect(wrapper.emittedByOrder()).toContainEqual({ - name: 'applyBatch', - args: [], - }); + expect(wrapper.emitted().applyBatch).toEqual([[]]); }); }); diff --git a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js index 6ae405017c9..b67f4cf12bf 100644 --- a/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js +++ b/spec/frontend/vue_shared/components/markdown/suggestion_diff_row_spec.js @@ -34,12 +34,25 @@ describe('SuggestionDiffRow', () => { const findOldLineWrapper = () => wrapper.find('.old_line'); const findNewLineWrapper = () => wrapper.find('.new_line'); + const findSuggestionContent = () => wrapper.find('[data-testid="suggestion-diff-content"]'); afterEach(() => { wrapper.destroy(); }); describe('renders correctly', () => { + it('renders the correct base suggestion markup', () => { + factory({ + propsData: { + line: oldLine, + }, + }); + + expect(findSuggestionContent().html()).toBe( + '<td data-testid="suggestion-diff-content" class="line_content old"><span class="line">oldrichtext</span></td>', + ); + }); + it('has the right classes on the wrapper', () => { factory({ propsData: { @@ -47,7 +60,12 @@ describe('SuggestionDiffRow', () => { }, }); - expect(wrapper.is('.line_holder')).toBe(true); + expect(wrapper.classes()).toContain('line_holder'); + expect( + findSuggestionContent() + .find('span') + .classes(), + ).toContain('line'); }); it('renders the rich text when it is available', () => { diff --git a/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js new file mode 100644 index 00000000000..8a7946fd7b1 --- /dev/null +++ b/spec/frontend/vue_shared/components/markdown/toolbar_button_spec.js @@ -0,0 +1,47 @@ +import { shallowMount } from '@vue/test-utils'; +import ToolbarButton from '~/vue_shared/components/markdown/toolbar_button.vue'; + +describe('toolbar_button', () => { + let wrapper; + + const defaultProps = { + buttonTitle: 'test button', + icon: 'rocket', + tag: 'test tag', + }; + + const createComponent = propUpdates => { + wrapper = shallowMount(ToolbarButton, { + propsData: { + ...defaultProps, + ...propUpdates, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const getButtonShortcutsAttr = () => { + return wrapper.find('button').attributes('data-md-shortcuts'); + }; + + describe('keyboard shortcuts', () => { + it.each` + shortcutsProp | mdShortcutsAttr + ${undefined} | ${JSON.stringify([])} + ${[]} | ${JSON.stringify([])} + ${'command+b'} | ${JSON.stringify(['command+b'])} + ${['command+b', 'ctrl+b']} | ${JSON.stringify(['command+b', 'ctrl+b'])} + `( + 'adds the attribute data-md-shortcuts="$mdShortcutsAttr" to the button when the shortcuts prop is $shortcutsProp', + ({ shortcutsProp, mdShortcutsAttr }) => { + createComponent({ shortcuts: shortcutsProp }); + + expect(getButtonShortcutsAttr()).toBe(mdShortcutsAttr); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js index ae8c9a0928e..61660f79b71 100644 --- a/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js +++ b/spec/frontend/vue_shared/components/notes/noteable_warning_spec.js @@ -1,11 +1,11 @@ import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; import NoteableWarning from '~/vue_shared/components/notes/noteable_warning.vue'; -import Icon from '~/vue_shared/components/icon.vue'; describe('Issue Warning Component', () => { let wrapper; - const findIcon = (w = wrapper) => w.find(Icon); + const findIcon = (w = wrapper) => w.find(GlIcon); const findLockedBlock = (w = wrapper) => w.find({ ref: 'locked' }); const findConfidentialBlock = (w = wrapper) => w.find({ ref: 'confidential' }); const findLockedAndConfidentialBlock = (w = wrapper) => w.find({ ref: 'lockedAndConfidential' }); @@ -69,7 +69,7 @@ describe('Issue Warning Component', () => { }); it('renders warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(true); + expect(wrapper.find(GlIcon).exists()).toBe(true); }); it('does not render information about locked noteable', () => { @@ -95,7 +95,7 @@ describe('Issue Warning Component', () => { }); it('does not render warning icon', () => { - expect(wrapper.find(Icon).exists()).toBe(false); + expect(wrapper.find(GlIcon).exists()).toBe(false); }); it('does not render information about locked noteable', () => { diff --git a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js index f73d3edec5d..bd4b6a463ab 100644 --- a/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js +++ b/spec/frontend/vue_shared/components/notes/timeline_entry_item_spec.js @@ -17,9 +17,9 @@ describe(`TimelineEntryItem`, () => { it('renders correctly', () => { factory(); - expect(wrapper.is('.timeline-entry')).toBe(true); + expect(wrapper.classes()).toContain('timeline-entry'); - expect(wrapper.contains('.timeline-entry-inner')).toBe(true); + expect(wrapper.find('.timeline-entry-inner').exists()).toBe(true); }); it('accepts default slot', () => { diff --git a/spec/frontend/vue_shared/components/ordered_layout_spec.js b/spec/frontend/vue_shared/components/ordered_layout_spec.js index e8667d9ee4a..eec153c3792 100644 --- a/spec/frontend/vue_shared/components/ordered_layout_spec.js +++ b/spec/frontend/vue_shared/components/ordered_layout_spec.js @@ -27,7 +27,9 @@ describe('Ordered Layout', () => { let wrapper; const verifyOrder = () => - wrapper.findAll('footer,header').wrappers.map(x => (x.is('footer') ? 'footer' : 'header')); + wrapper + .findAll('footer,header') + .wrappers.map(x => (x.element.tagName === 'FOOTER' ? 'footer' : 'header')); const createComponent = (props = {}) => { wrapper = mount(TestComponent, { diff --git a/spec/frontend/vue_shared/components/paginated_list_spec.js b/spec/frontend/vue_shared/components/paginated_list_spec.js index 46e45296c37..c0ee49f194f 100644 --- a/spec/frontend/vue_shared/components/paginated_list_spec.js +++ b/spec/frontend/vue_shared/components/paginated_list_spec.js @@ -48,7 +48,7 @@ describe('Pagination links component', () => { describe('rendering', () => { it('it renders the gl-paginated-list', () => { - expect(wrapper.contains('ul.list-group')).toBe(true); + expect(wrapper.find('ul.list-group').exists()).toBe(true); expect(wrapper.findAll('li.list-group-item').length).toBe(2); }); }); diff --git a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js index 385134c4a3f..649eb2643f1 100644 --- a/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js +++ b/spec/frontend/vue_shared/components/project_selector/project_list_item_spec.js @@ -29,7 +29,7 @@ describe('ProjectListItem component', () => { it('does not render a check mark icon if selected === false', () => { wrapper = shallowMount(Component, options); - expect(wrapper.contains('.js-selected-icon.js-unselected')).toBe(true); + expect(wrapper.find('.js-selected-icon').exists()).toBe(false); }); it('renders a check mark icon if selected === true', () => { @@ -37,7 +37,7 @@ describe('ProjectListItem component', () => { wrapper = shallowMount(Component, options); - expect(wrapper.contains('.js-selected-icon.js-selected')).toBe(true); + expect(wrapper.find('.js-selected-icon').exists()).toBe(true); }); it(`emits a "clicked" event when clicked`, () => { @@ -53,7 +53,7 @@ describe('ProjectListItem component', () => { it(`renders the project avatar`, () => { wrapper = shallowMount(Component, options); - expect(wrapper.contains('.js-project-avatar')).toBe(true); + expect(wrapper.find('.js-project-avatar').exists()).toBe(true); }); it(`renders a simple namespace name with a trailing slash`, () => { diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap new file mode 100644 index 00000000000..16094a42668 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/code_instruction_spec.js.snap @@ -0,0 +1,60 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`Package code instruction multiline to match the snapshot 1`] = ` +<div> + <pre + class="gl-font-monospace" + data-testid="multiline-instruction" + > + this is some +multiline text + </pre> +</div> +`; + +exports[`Package code instruction single line to match the default snapshot 1`] = ` +<div + class="gl-mb-3" +> + <label + for="instruction-input_2" + > + foo_label + </label> + + <div + class="input-group gl-mb-3" + > + <input + class="form-control gl-font-monospace" + data-testid="instruction-input" + id="instruction-input_2" + readonly="readonly" + type="text" + /> + + <span + class="input-group-append" + data-testid="instruction-button" + > + <button + class="btn input-group-text btn-secondary btn-md btn-default" + data-clipboard-text="npm i @my-package" + title="Copy npm install command" + type="button" + > + <!----> + + <svg + class="gl-icon s16" + data-testid="copy-to-clipboard-icon" + > + <use + href="#copy-to-clipboard" + /> + </svg> + </button> + </span> + </div> +</div> +`; diff --git a/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap new file mode 100644 index 00000000000..2abae33bc19 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/__snapshots__/history_item_spec.js.snap @@ -0,0 +1,42 @@ +// Jest Snapshot v1, https://goo.gl/fbAQLP + +exports[`History Item renders the correct markup 1`] = ` +<li + class="timeline-entry system-note note-wrapper gl-mb-6!" +> + <div + class="timeline-entry-inner" + > + <div + class="timeline-icon" + > + <gl-icon-stub + name="pencil" + size="16" + /> + </div> + + <div + class="timeline-content" + > + <div + class="note-header" + > + <span> + <div + data-testid="default-slot" + /> + </span> + </div> + + <div + class="note-body" + > + <div + data-testid="body-slot" + /> + </div> + </div> + </div> +</li> +`; diff --git a/spec/frontend/vue_shared/components/registry/code_instruction_spec.js b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js new file mode 100644 index 00000000000..84c738764a3 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/code_instruction_spec.js @@ -0,0 +1,117 @@ +import { mount } from '@vue/test-utils'; +import Tracking from '~/tracking'; +import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue'; +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; + +describe('Package code instruction', () => { + let wrapper; + + const defaultProps = { + instruction: 'npm i @my-package', + copyText: 'Copy npm install command', + }; + + function createComponent(props = {}) { + wrapper = mount(CodeInstruction, { + propsData: { + ...defaultProps, + ...props, + }, + }); + } + + const findCopyButton = () => wrapper.find(ClipboardButton); + const findInputElement = () => wrapper.find('[data-testid="instruction-input"]'); + const findMultilineInstruction = () => wrapper.find('[data-testid="multiline-instruction"]'); + + afterEach(() => { + wrapper.destroy(); + }); + + describe('single line', () => { + beforeEach(() => + createComponent({ + label: 'foo_label', + }), + ); + + it('to match the default snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('multiline', () => { + beforeEach(() => + createComponent({ + instruction: 'this is some\nmultiline text', + copyText: 'Copy the command', + label: 'foo_label', + multiline: true, + }), + ); + + it('to match the snapshot', () => { + expect(wrapper.element).toMatchSnapshot(); + }); + }); + + describe('tracking', () => { + let eventSpy; + const trackingAction = 'test_action'; + const trackingLabel = 'foo_label'; + + beforeEach(() => { + eventSpy = jest.spyOn(Tracking, 'event'); + }); + + it('should not track when no trackingAction is provided', () => { + createComponent(); + findCopyButton().trigger('click'); + + expect(eventSpy).toHaveBeenCalledTimes(0); + }); + + describe('when trackingAction is provided for single line', () => { + beforeEach(() => + createComponent({ + trackingAction, + trackingLabel, + }), + ); + + it('should track when copying from the input', () => { + findInputElement().trigger('copy'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label: trackingLabel, + }); + }); + + it('should track when the copy button is pressed', () => { + findCopyButton().trigger('click'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label: trackingLabel, + }); + }); + }); + + describe('when trackingAction is provided for multiline', () => { + beforeEach(() => + createComponent({ + trackingAction, + trackingLabel, + multiline: true, + }), + ); + + it('should track when copying from the multiline pre element', () => { + findMultilineInstruction().trigger('copy'); + + expect(eventSpy).toHaveBeenCalledWith(undefined, trackingAction, { + label: trackingLabel, + }); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/details_row_spec.js b/spec/frontend/vue_shared/components/registry/details_row_spec.js new file mode 100644 index 00000000000..16a55b84787 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/details_row_spec.js @@ -0,0 +1,71 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import component from '~/vue_shared/components/registry/details_row.vue'; + +describe('DetailsRow', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + const mountComponent = props => { + wrapper = shallowMount(component, { + propsData: { + icon: 'clock', + ...props, + }, + slots: { + default: '<div data-testid="default-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('has a default slot', () => { + mountComponent(); + expect(findDefaultSlot().exists()).toBe(true); + }); + + describe('icon prop', () => { + it('contains an icon', () => { + mountComponent(); + expect(findIcon().exists()).toBe(true); + }); + + it('icon has the correct props', () => { + mountComponent(); + expect(findIcon().props()).toMatchObject({ + name: 'clock', + }); + }); + }); + + describe('padding prop', () => { + it('padding has a default', () => { + mountComponent(); + expect(wrapper.classes('gl-py-2')).toBe(true); + }); + + it('is reflected in the template', () => { + mountComponent({ padding: 'gl-py-4' }); + expect(wrapper.classes('gl-py-4')).toBe(true); + }); + }); + + describe('dashed prop', () => { + const borderClasses = ['gl-border-b-solid', 'gl-border-gray-100', 'gl-border-b-1']; + it('by default component has no border', () => { + mountComponent(); + expect(wrapper.classes).not.toEqual(expect.arrayContaining(borderClasses)); + }); + + it('has a border when dashed is true', () => { + mountComponent({ dashed: true }); + expect(wrapper.classes()).toEqual(expect.arrayContaining(borderClasses)); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/history_item_spec.js b/spec/frontend/vue_shared/components/registry/history_item_spec.js new file mode 100644 index 00000000000..d51ddda2e3e --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/history_item_spec.js @@ -0,0 +1,67 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import TimelineEntryItem from '~/vue_shared/components/notes/timeline_entry_item.vue'; +import component from '~/vue_shared/components/registry/history_item.vue'; + +describe('History Item', () => { + let wrapper; + const defaultProps = { + icon: 'pencil', + }; + + const mountComponent = () => { + wrapper = shallowMount(component, { + propsData: { ...defaultProps }, + stubs: { + TimelineEntryItem, + }, + slots: { + default: '<div data-testid="default-slot"></div>', + body: '<div data-testid="body-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findTimelineEntry = () => wrapper.find(TimelineEntryItem); + const findGlIcon = () => wrapper.find(GlIcon); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + const findBodySlot = () => wrapper.find('[data-testid="body-slot"]'); + + it('renders the correct markup', () => { + mountComponent(); + + expect(wrapper.element).toMatchSnapshot(); + }); + + it('has a default slot', () => { + mountComponent(); + + expect(findDefaultSlot().exists()).toBe(true); + }); + + it('has a body slot', () => { + mountComponent(); + + expect(findBodySlot().exists()).toBe(true); + }); + + it('has a timeline entry', () => { + mountComponent(); + + expect(findTimelineEntry().exists()).toBe(true); + }); + + it('has an icon', () => { + mountComponent(); + + const icon = findGlIcon(); + + expect(icon.exists()).toBe(true); + expect(icon.attributes('name')).toBe(defaultProps.icon); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/list_item_spec.js b/spec/frontend/vue_shared/components/registry/list_item_spec.js new file mode 100644 index 00000000000..e2cfdedb4bf --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/list_item_spec.js @@ -0,0 +1,135 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/vue_shared/components/registry/list_item.vue'; + +describe('list item', () => { + let wrapper; + + const findLeftActionSlot = () => wrapper.find('[data-testid="left-action"]'); + const findLeftPrimarySlot = () => wrapper.find('[data-testid="left-primary"]'); + const findLeftSecondarySlot = () => wrapper.find('[data-testid="left-secondary"]'); + const findRightPrimarySlot = () => wrapper.find('[data-testid="right-primary"]'); + const findRightSecondarySlot = () => wrapper.find('[data-testid="right-secondary"]'); + const findRightActionSlot = () => wrapper.find('[data-testid="right-action"]'); + const findDetailsSlot = name => wrapper.find(`[data-testid="${name}"]`); + const findToggleDetailsButton = () => wrapper.find(GlButton); + + const mountComponent = (propsData, slots) => { + wrapper = shallowMount(component, { + propsData, + slots: { + 'left-action': '<div data-testid="left-action" />', + 'left-primary': '<div data-testid="left-primary" />', + 'left-secondary': '<div data-testid="left-secondary" />', + 'right-primary': '<div data-testid="right-primary" />', + 'right-secondary': '<div data-testid="right-secondary" />', + 'right-action': '<div data-testid="right-action" />', + ...slots, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe.each` + slotName | finderFunction + ${'left-primary'} | ${findLeftPrimarySlot} + ${'left-secondary'} | ${findLeftSecondarySlot} + ${'right-primary'} | ${findRightPrimarySlot} + ${'right-secondary'} | ${findRightSecondarySlot} + ${'left-action'} | ${findLeftActionSlot} + ${'right-action'} | ${findRightActionSlot} + `('$slotName slot', ({ finderFunction, slotName }) => { + it('exist when the slot is filled', () => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); + + it('does not exist when the slot is empty', () => { + mountComponent({}, { [slotName]: '' }); + + expect(finderFunction().exists()).toBe(false); + }); + }); + + describe.each` + slotNames + ${['details_foo']} + ${['details_foo', 'details_bar']} + ${['details_foo', 'details_bar', 'details_baz']} + `('$slotNames details slots', ({ slotNames }) => { + const slotMocks = slotNames.reduce((acc, current) => { + acc[current] = `<div data-testid="${current}" />`; + return acc; + }, {}); + + it('are visible when details is shown', async () => { + mountComponent({}, slotMocks); + + await wrapper.vm.$nextTick(); + findToggleDetailsButton().vm.$emit('click'); + + await wrapper.vm.$nextTick(); + slotNames.forEach(name => { + expect(findDetailsSlot(name).exists()).toBe(true); + }); + }); + it('are not visible when details are not shown', () => { + mountComponent({}, slotMocks); + + slotNames.forEach(name => { + expect(findDetailsSlot(name).exists()).toBe(false); + }); + }); + }); + + describe('details toggle button', () => { + it('is visible when at least one details slot exists', async () => { + mountComponent({}, { details_foo: '<span></span>' }); + await wrapper.vm.$nextTick(); + expect(findToggleDetailsButton().exists()).toBe(true); + }); + + it('is hidden without details slot', () => { + mountComponent(); + expect(findToggleDetailsButton().exists()).toBe(false); + }); + }); + + describe('disabled prop', () => { + it('when true applies disabled-content class', () => { + mountComponent({ disabled: true }); + + expect(wrapper.classes('disabled-content')).toBe(true); + }); + + it('when false does not apply disabled-content class', () => { + mountComponent({ disabled: false }); + + expect(wrapper.classes('disabled-content')).toBe(false); + }); + }); + + describe('borders and selection', () => { + it.each` + first | selected | shouldHave | shouldNotHave + ${true} | ${true} | ${['gl-bg-blue-50', 'gl-border-blue-200']} | ${['gl-border-t-transparent', 'gl-border-t-gray-100']} + ${false} | ${true} | ${['gl-bg-blue-50', 'gl-border-blue-200']} | ${['gl-border-t-transparent', 'gl-border-t-gray-100']} + ${true} | ${false} | ${['gl-border-b-gray-100']} | ${['gl-bg-blue-50', 'gl-border-blue-200']} + ${false} | ${false} | ${['gl-border-b-gray-100']} | ${['gl-bg-blue-50', 'gl-border-blue-200']} + `( + 'when first is $first and selected is $selected', + ({ first, selected, shouldHave, shouldNotHave }) => { + mountComponent({ first, selected }); + + expect(wrapper.classes()).toEqual(expect.arrayContaining(shouldHave)); + + expect(wrapper.classes()).toEqual(expect.not.arrayContaining(shouldNotHave)); + }, + ); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/metadata_item_spec.js b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js new file mode 100644 index 00000000000..ff968ff1831 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/metadata_item_spec.js @@ -0,0 +1,101 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon, GlLink } from '@gitlab/ui'; +import component from '~/vue_shared/components/registry/metadata_item.vue'; +import TooltipOnTruncate from '~/vue_shared/components/tooltip_on_truncate.vue'; + +describe('Metadata Item', () => { + let wrapper; + const defaultProps = { + text: 'foo', + }; + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + const findIcon = () => wrapper.find(GlIcon); + const findLink = (w = wrapper) => w.find(GlLink); + const findText = () => wrapper.find('[data-testid="metadata-item-text"]'); + const findTooltipOnTruncate = (w = wrapper) => w.find(TooltipOnTruncate); + + describe.each(['xs', 's', 'm', 'l', 'xl'])('size class', size => { + const className = `mw-${size}`; + + it(`${size} is assigned correctly to text`, () => { + mountComponent({ ...defaultProps, size }); + + expect(findText().classes()).toContain(className); + }); + + it(`${size} is assigned correctly to link`, () => { + mountComponent({ ...defaultProps, link: 'foo', size }); + + expect(findTooltipOnTruncate().classes()).toContain(className); + }); + }); + + describe('text', () => { + it('display a proper text', () => { + mountComponent(); + + expect(findText().text()).toBe(defaultProps.text); + }); + + it('uses tooltip_on_truncate', () => { + mountComponent(); + + const tooltip = findTooltipOnTruncate(findText()); + expect(tooltip.exists()).toBe(true); + expect(tooltip.attributes('title')).toBe(defaultProps.text); + }); + }); + + describe('link', () => { + it('if a link prop is passed shows a link and hides the text', () => { + mountComponent({ ...defaultProps, link: 'bar' }); + + expect(findLink().exists()).toBe(true); + expect(findText().exists()).toBe(false); + + expect(findLink().attributes('href')).toBe('bar'); + }); + + it('uses tooltip_on_truncate', () => { + mountComponent({ ...defaultProps, link: 'bar' }); + + const tooltip = findTooltipOnTruncate(); + expect(tooltip.exists()).toBe(true); + expect(tooltip.attributes('title')).toBe(defaultProps.text); + expect(findLink(tooltip).exists()).toBe(true); + }); + + it('hides the link and shows the test if a link prop is not passed', () => { + mountComponent(); + + expect(findText().exists()).toBe(true); + expect(findLink().exists()).toBe(false); + }); + }); + + describe('icon', () => { + it('if a icon prop is passed shows a icon', () => { + mountComponent({ ...defaultProps, icon: 'pencil' }); + + expect(findIcon().exists()).toBe(true); + expect(findIcon().props('name')).toBe('pencil'); + }); + + it('if a icon prop is not passed hides the icon', () => { + mountComponent(); + + expect(findIcon().exists()).toBe(false); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/registry/title_area_spec.js b/spec/frontend/vue_shared/components/registry/title_area_spec.js new file mode 100644 index 00000000000..6740d6097a4 --- /dev/null +++ b/spec/frontend/vue_shared/components/registry/title_area_spec.js @@ -0,0 +1,98 @@ +import { GlAvatar } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/vue_shared/components/registry/title_area.vue'; + +describe('title area', () => { + let wrapper; + + const findSubHeaderSlot = () => wrapper.find('[data-testid="sub-header"]'); + const findRightActionsSlot = () => wrapper.find('[data-testid="right-actions"]'); + const findMetadataSlot = name => wrapper.find(`[data-testid="${name}"]`); + const findTitle = () => wrapper.find('[data-testid="title"]'); + const findAvatar = () => wrapper.find(GlAvatar); + + const mountComponent = ({ propsData = { title: 'foo' }, slots } = {}) => { + wrapper = shallowMount(component, { + propsData, + slots: { + 'sub-header': '<div data-testid="sub-header" />', + 'right-actions': '<div data-testid="right-actions" />', + ...slots, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('title', () => { + it('if slot is not present defaults to prop', () => { + mountComponent(); + + expect(findTitle().text()).toBe('foo'); + }); + it('if slot is present uses slot', () => { + mountComponent({ + slots: { + title: 'slot_title', + }, + }); + expect(findTitle().text()).toBe('slot_title'); + }); + }); + + describe('avatar', () => { + it('is shown if avatar props exist', () => { + mountComponent({ propsData: { title: 'foo', avatar: 'baz' } }); + + expect(findAvatar().props('src')).toBe('baz'); + }); + + it('is hidden if avatar props does not exist', () => { + mountComponent(); + + expect(findAvatar().exists()).toBe(false); + }); + }); + + describe.each` + slotName | finderFunction + ${'sub-header'} | ${findSubHeaderSlot} + ${'right-actions'} | ${findRightActionsSlot} + `('$slotName slot', ({ finderFunction, slotName }) => { + it('exist when the slot is filled', () => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); + + it('does not exist when the slot is empty', () => { + mountComponent({ slots: { [slotName]: '' } }); + + expect(finderFunction().exists()).toBe(false); + }); + }); + + describe.each` + slotNames + ${['metadata_foo']} + ${['metadata_foo', 'metadata_bar']} + ${['metadata_foo', 'metadata_bar', 'metadata_baz']} + `('$slotNames metadata slots', ({ slotNames }) => { + const slotMocks = slotNames.reduce((acc, current) => { + acc[current] = `<div data-testid="${current}" />`; + return acc; + }, {}); + + it('exist when the slot is present', async () => { + mountComponent({ slots: slotMocks }); + + await wrapper.vm.$nextTick(); + slotNames.forEach(name => { + expect(findMetadataSlot(name).exists()).toBe(true); + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/remove_member_modal_spec.js b/spec/frontend/vue_shared/components/remove_member_modal_spec.js index 2d380b25a0a..78fe6d53eee 100644 --- a/spec/frontend/vue_shared/components/remove_member_modal_spec.js +++ b/spec/frontend/vue_shared/components/remove_member_modal_spec.js @@ -48,7 +48,7 @@ describe('RemoveMemberModal', () => { }); it(`${checkboxTestDescription}`, () => { - expect(wrapper.contains(GlFormCheckbox)).toBe(checkboxExpected); + expect(wrapper.find(GlFormCheckbox).exists()).toBe(checkboxExpected); }); it('submits the form when the modal is submitted', () => { diff --git a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap index 103b53cb280..3990248d021 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap +++ b/spec/frontend/vue_shared/components/resizable_chart/__snapshots__/skeleton_loader_spec.js.snap @@ -1,324 +1,433 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Resizable Skeleton Loader default setup renders the bars, labels, and grid with correct position, size, and rx percentages 1`] = ` -<gl-skeleton-loader-stub - baseurl="" - height="130" - preserveaspectratio="xMidYMid meet" - width="400" +<div + class="gl-px-8" > - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="30%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="60%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="90%" - /> - - <rect - data-testid="skeleton-chart-bar" - height="5%" - rx="0.4%" - width="6%" - x="5.875%" - y="85%" - /> - <rect - data-testid="skeleton-chart-bar" - height="7%" - rx="0.4%" - width="6%" - x="17.625%" - y="83%" - /> - <rect - data-testid="skeleton-chart-bar" - height="9%" - rx="0.4%" - width="6%" - x="29.375%" - y="81%" - /> - <rect - data-testid="skeleton-chart-bar" - height="14%" - rx="0.4%" - width="6%" - x="41.125%" - y="76%" - /> - <rect - data-testid="skeleton-chart-bar" - height="21%" - rx="0.4%" - width="6%" - x="52.875%" - y="69%" - /> - <rect - data-testid="skeleton-chart-bar" - height="35%" - rx="0.4%" - width="6%" - x="64.625%" - y="55%" - /> - <rect - data-testid="skeleton-chart-bar" - height="50%" - rx="0.4%" - width="6%" - x="76.375%" - y="40%" - /> - <rect - data-testid="skeleton-chart-bar" - height="80%" - rx="0.4%" - width="6%" - x="88.125%" - y="10%" - /> - - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="6.875%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="18.625%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="30.375%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="42.125%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="53.875%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="65.625%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="77.375%" - y="95%" - /> - <rect - data-testid="skeleton-chart-label" - height="5%" - rx="0.4%" - width="4%" - x="89.125%" - y="95%" - /> -</gl-skeleton-loader-stub> + <svg + class="gl-skeleton-loader" + preserveAspectRatio="xMidYMid meet" + version="1.1" + viewBox="0 0 400 130" + > + <rect + clip-path="url(#null-idClip)" + height="130" + style="fill: url(#null-idGradient);" + width="400" + x="0" + y="0" + /> + <defs> + <clippath + id="null-idClip" + > + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.4%" + width="4%" + x="6%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.4%" + width="4%" + x="18%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.4%" + width="4%" + x="30%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.4%" + width="4%" + x="42%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.4%" + width="4%" + x="54%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.4%" + width="4%" + x="66%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.4%" + width="4%" + x="78%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.4%" + width="4%" + x="90%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="6.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="18.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="30.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="42.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="54.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="66.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="78.5%" + y="97%" + /> + <rect + data-testid="skeleton-chart-label" + height="3%" + rx="0.4%" + width="3%" + x="90.5%" + y="97%" + /> + </clippath> + <lineargradient + id="null-idGradient" + > + <stop + class="primary-stop" + offset="0%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-2; 1" + /> + </stop> + <stop + class="secondary-stop" + offset="50%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1.5; 1.5" + /> + </stop> + <stop + class="primary-stop" + offset="100%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1; 2" + /> + </stop> + </lineargradient> + </defs> + </svg> +</div> `; exports[`Resizable Skeleton Loader with custom settings renders the correct position, and size percentages for bars and labels with different settings 1`] = ` -<gl-skeleton-loader-stub - baseurl="" - height="130" - preserveaspectratio="xMidYMid meet" - uniquekey="" - width="400" +<div + class="gl-px-8" > - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="30%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="60%" - /> - <rect - data-testid="skeleton-chart-grid" - height="1px" - width="100%" - x="0" - y="90%" - /> - - <rect - data-testid="skeleton-chart-bar" - height="5%" - rx="0.6%" - width="3%" - x="6.0625%" - y="85%" - /> - <rect - data-testid="skeleton-chart-bar" - height="7%" - rx="0.6%" - width="3%" - x="18.1875%" - y="83%" - /> - <rect - data-testid="skeleton-chart-bar" - height="9%" - rx="0.6%" - width="3%" - x="30.3125%" - y="81%" - /> - <rect - data-testid="skeleton-chart-bar" - height="14%" - rx="0.6%" - width="3%" - x="42.4375%" - y="76%" - /> - <rect - data-testid="skeleton-chart-bar" - height="21%" - rx="0.6%" - width="3%" - x="54.5625%" - y="69%" - /> - <rect - data-testid="skeleton-chart-bar" - height="35%" - rx="0.6%" - width="3%" - x="66.6875%" - y="55%" - /> - <rect - data-testid="skeleton-chart-bar" - height="50%" - rx="0.6%" - width="3%" - x="78.8125%" - y="40%" - /> - <rect - data-testid="skeleton-chart-bar" - height="80%" - rx="0.6%" - width="3%" - x="90.9375%" - y="10%" - /> - - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="4.0625%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="16.1875%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="28.3125%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="40.4375%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="52.5625%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="64.6875%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="76.8125%" - y="98%" - /> - <rect - data-testid="skeleton-chart-label" - height="2%" - rx="0.6%" - width="7%" - x="88.9375%" - y="98%" - /> -</gl-skeleton-loader-stub> + <svg + class="gl-skeleton-loader" + preserveAspectRatio="xMidYMid meet" + version="1.1" + viewBox="0 0 400 130" + > + <rect + clip-path="url(#-idClip)" + height="130" + style="fill: url(#-idGradient);" + width="400" + x="0" + y="0" + /> + <defs> + <clippath + id="-idClip" + > + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="30%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="60%" + /> + <rect + data-testid="skeleton-chart-grid" + height="1px" + width="100%" + x="0" + y="90%" + /> + + <rect + data-testid="skeleton-chart-bar" + height="5%" + rx="0.6%" + width="3%" + x="6.0625%" + y="85%" + /> + <rect + data-testid="skeleton-chart-bar" + height="7%" + rx="0.6%" + width="3%" + x="18.1875%" + y="83%" + /> + <rect + data-testid="skeleton-chart-bar" + height="9%" + rx="0.6%" + width="3%" + x="30.3125%" + y="81%" + /> + <rect + data-testid="skeleton-chart-bar" + height="14%" + rx="0.6%" + width="3%" + x="42.4375%" + y="76%" + /> + <rect + data-testid="skeleton-chart-bar" + height="21%" + rx="0.6%" + width="3%" + x="54.5625%" + y="69%" + /> + <rect + data-testid="skeleton-chart-bar" + height="35%" + rx="0.6%" + width="3%" + x="66.6875%" + y="55%" + /> + <rect + data-testid="skeleton-chart-bar" + height="50%" + rx="0.6%" + width="3%" + x="78.8125%" + y="40%" + /> + <rect + data-testid="skeleton-chart-bar" + height="80%" + rx="0.6%" + width="3%" + x="90.9375%" + y="10%" + /> + + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="4.0625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="16.1875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="28.3125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="40.4375%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="52.5625%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="64.6875%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="76.8125%" + y="98%" + /> + <rect + data-testid="skeleton-chart-label" + height="2%" + rx="0.6%" + width="7%" + x="88.9375%" + y="98%" + /> + </clippath> + <lineargradient + id="-idGradient" + > + <stop + class="primary-stop" + offset="0%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-2; 1" + /> + </stop> + <stop + class="secondary-stop" + offset="50%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1.5; 1.5" + /> + </stop> + <stop + class="primary-stop" + offset="100%" + > + <animate + attributeName="offset" + dur="1s" + repeatCount="indefinite" + values="-1; 2" + /> + </stop> + </lineargradient> + </defs> + </svg> +</div> `; diff --git a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js index 7facd02e596..bfc3aeb0303 100644 --- a/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js +++ b/spec/frontend/vue_shared/components/resizable_chart/skeleton_loader_spec.js @@ -1,11 +1,11 @@ -import { shallowMount } from '@vue/test-utils'; +import { mount } from '@vue/test-utils'; import ChartSkeletonLoader from '~/vue_shared/components/resizable_chart/skeleton_loader.vue'; describe('Resizable Skeleton Loader', () => { let wrapper; const createComponent = (propsData = {}) => { - wrapper = shallowMount(ChartSkeletonLoader, { + wrapper = mount(ChartSkeletonLoader, { propsData, }); }; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js index cafe53e6bb2..a823d04024d 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_custom_renderer_spec.js @@ -5,8 +5,13 @@ describe('Build Custom Renderer Service', () => { it('should return an object with the default renderer functions when lacking arguments', () => { expect(buildCustomHTMLRenderer()).toEqual( expect.objectContaining({ - list: expect.any(Function), + htmlBlock: expect.any(Function), + htmlInline: expect.any(Function), + heading: expect.any(Function), + item: expect.any(Function), + paragraph: expect.any(Function), text: expect.any(Function), + softbreak: expect.any(Function), }), ); }); @@ -20,8 +25,6 @@ describe('Build Custom Renderer Service', () => { expect(buildCustomHTMLRenderer(customRenderers)).toEqual( expect.objectContaining({ html: expect.any(Function), - list: expect.any(Function), - text: expect.any(Function), }), ); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js index a90d3528d60..fd745c21bb6 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer_spec.js @@ -1,9 +1,10 @@ import buildHTMLToMarkdownRenderer from '~/vue_shared/components/rich_content_editor/services/build_html_to_markdown_renderer'; +import { attributeDefinition } from './renderers/mock_data'; -describe('HTMLToMarkdownRenderer', () => { +describe('rich_content_editor/services/html_to_markdown_renderer', () => { let baseRenderer; let htmlToMarkdownRenderer; - const NODE = { nodeValue: 'mock_node' }; + let fakeNode; beforeEach(() => { baseRenderer = { @@ -12,14 +13,20 @@ describe('HTMLToMarkdownRenderer', () => { getSpaceControlled: jest.fn(input => `space controlled ${input}`), convert: jest.fn(), }; + + fakeNode = { nodeValue: 'mock_node', dataset: {} }; + }); + + afterEach(() => { + htmlToMarkdownRenderer = null; }); describe('TEXT_NODE visitor', () => { it('composes getSpaceControlled, getSpaceCollapsedText, and trim services', () => { htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); - expect(htmlToMarkdownRenderer.TEXT_NODE(NODE)).toBe( - `space controlled trimmed space collapsed ${NODE.nodeValue}`, + expect(htmlToMarkdownRenderer.TEXT_NODE(fakeNode)).toBe( + `space controlled trimmed space collapsed ${fakeNode.nodeValue}`, ); }); }); @@ -43,8 +50,8 @@ describe('HTMLToMarkdownRenderer', () => { baseRenderer.convert.mockReturnValueOnce(list); - expect(htmlToMarkdownRenderer['LI OL, LI UL'](NODE, list)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, list); + expect(htmlToMarkdownRenderer['LI OL, LI UL'](fakeNode, list)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, list); }); }); @@ -62,10 +69,21 @@ describe('HTMLToMarkdownRenderer', () => { }); baseRenderer.convert.mockReturnValueOnce(listItem); - expect(htmlToMarkdownRenderer['UL LI'](NODE, listItem)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, listItem); + expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, listItem); }, ); + + it('detects attribute definitions and attaches them to the list item', () => { + const listItem = '- list item'; + const result = `${listItem}\n${attributeDefinition}\n`; + + fakeNode.dataset.attributeDefinition = attributeDefinition; + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + baseRenderer.convert.mockReturnValueOnce(`${listItem}\n`); + + expect(htmlToMarkdownRenderer['UL LI'](fakeNode, listItem)).toBe(result); + }); }); describe('OL LI visitor', () => { @@ -85,8 +103,8 @@ describe('HTMLToMarkdownRenderer', () => { }); baseRenderer.convert.mockReturnValueOnce(listItem); - expect(htmlToMarkdownRenderer['OL LI'](NODE, subContent)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, subContent); + expect(htmlToMarkdownRenderer['OL LI'](fakeNode, subContent)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, subContent); }, ); }); @@ -105,8 +123,8 @@ describe('HTMLToMarkdownRenderer', () => { baseRenderer.convert.mockReturnValueOnce(input); - expect(htmlToMarkdownRenderer['STRONG, B'](NODE, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input); + expect(htmlToMarkdownRenderer['STRONG, B'](fakeNode, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); }, ); }); @@ -125,9 +143,50 @@ describe('HTMLToMarkdownRenderer', () => { baseRenderer.convert.mockReturnValueOnce(input); - expect(htmlToMarkdownRenderer['EM, I'](NODE, input)).toBe(result); - expect(baseRenderer.convert).toHaveBeenCalledWith(NODE, input); + expect(htmlToMarkdownRenderer['EM, I'](fakeNode, input)).toBe(result); + expect(baseRenderer.convert).toHaveBeenCalledWith(fakeNode, input); }, ); }); + + describe('H1, H2, H3, H4, H5, H6 visitor', () => { + it('detects attribute definitions and attaches them to the heading', () => { + const heading = 'heading text'; + const result = `${heading.trimRight()}\n${attributeDefinition}\n\n`; + + fakeNode.dataset.attributeDefinition = attributeDefinition; + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + baseRenderer.convert.mockReturnValueOnce(`${heading}\n\n`); + + expect(htmlToMarkdownRenderer['H1, H2, H3, H4, H5, H6'](fakeNode, heading)).toBe(result); + }); + }); + + describe('PRE CODE', () => { + let node; + const subContent = 'sub content'; + const originalConverterResult = 'base result'; + + beforeEach(() => { + node = document.createElement('PRE'); + + node.innerText = 'reference definition content'; + node.dataset.sseReferenceDefinition = true; + + baseRenderer.convert.mockReturnValueOnce(originalConverterResult); + htmlToMarkdownRenderer = buildHTMLToMarkdownRenderer(baseRenderer); + }); + + it('returns raw text when pre node has sse-reference-definitions class', () => { + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe( + `\n\n${node.innerText}\n\n`, + ); + }); + + it('returns base result when pre node does not have sse-reference-definitions class', () => { + delete node.dataset.sseReferenceDefinition; + + expect(htmlToMarkdownRenderer['PRE CODE'](node, subContent)).toBe(originalConverterResult); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js index 660c21281fd..5cf3961819e 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/mock_data.js @@ -1,12 +1,6 @@ // Node spec helpers -export const buildMockTextNode = literal => { - return { - firstChild: null, - literal, - type: 'text', - }; -}; +export const buildMockTextNode = literal => ({ literal, type: 'text' }); export const normalTextNode = buildMockTextNode('This is just normal text.'); @@ -23,17 +17,20 @@ const buildMockUneditableOpenToken = type => { }; }; -const buildMockUneditableCloseToken = type => { - return { type: 'closeTag', tagName: type }; +const buildMockTextToken = content => { + return { + type: 'text', + tagName: null, + content, + }; }; -export const originToken = { - type: 'text', - tagName: null, - content: '{:.no_toc .hidden-md .hidden-lg}', -}; +const buildMockUneditableCloseToken = type => ({ type: 'closeTag', tagName: type }); + +export const originToken = buildMockTextToken('{:.no_toc .hidden-md .hidden-lg}'); +const uneditableOpenToken = buildMockUneditableOpenToken('div'); +export const uneditableOpenTokens = [uneditableOpenToken, originToken]; export const uneditableCloseToken = buildMockUneditableCloseToken('div'); -export const uneditableOpenTokens = [buildMockUneditableOpenToken('div'), originToken]; export const uneditableCloseTokens = [originToken, uneditableCloseToken]; export const uneditableTokens = [...uneditableOpenTokens, uneditableCloseToken]; @@ -41,6 +38,7 @@ export const originInlineToken = { type: 'text', content: '<i>Inline</i> content', }; + export const uneditableInlineTokens = [ buildMockUneditableOpenToken('a'), originInlineToken, @@ -48,11 +46,9 @@ export const uneditableInlineTokens = [ ]; export const uneditableBlockTokens = [ - buildMockUneditableOpenToken('div'), - { - type: 'text', - tagName: null, - content: '<div><h1>Some header</h1><p>Some paragraph</p></div>', - }, - buildMockUneditableCloseToken('div'), + uneditableOpenToken, + buildMockTextToken('<div><h1>Some header</h1><p>Some paragraph</p></div>'), + uneditableCloseToken, ]; + +export const attributeDefinition = '{:.no_toc .hidden-md .hidden-lg}'; diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js new file mode 100644 index 00000000000..69fd9a67a21 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition_spec.js @@ -0,0 +1,25 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_attribute_definition'; +import { attributeDefinition } from './mock_data'; + +describe('rich_content_editor/renderers/render_attribute_definition', () => { + describe('canRender', () => { + it.each` + input | result + ${{ literal: attributeDefinition }} | ${true} + ${{ literal: `FOO${attributeDefinition}` }} | ${false} + ${{ literal: `${attributeDefinition}BAR` }} | ${false} + ${{ literal: 'foobar' }} | ${false} + `('returns $result when input is $input', ({ input, result }) => { + expect(renderer.canRender(input)).toBe(result); + }); + }); + + describe('render', () => { + it('returns an empty HTML comment', () => { + expect(renderer.render()).toEqual({ + type: 'html', + content: '<!-- sse-attribute-definition -->', + }); + }); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js new file mode 100644 index 00000000000..76abc1ec3d8 --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_heading_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_heading'; +import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_heading', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js index f4a06b91a10..b3d9576f38b 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph_spec.js @@ -1,5 +1,4 @@ import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_identifier_paragraph'; -import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { buildMockTextNode } from './mock_data'; @@ -17,7 +16,7 @@ const identifierParagraphNode = buildMockParagraphNode( `[another-identifier]: https://example.com "This example has a title" [identifier]: http://example1.com [this link]: http://example2.com`, ); -describe('Render Identifier Paragraph renderer', () => { +describe('rich_content_editor/renderers_render_identifier_paragraph', () => { describe('canRender', () => { it.each` node | paragraph | target @@ -37,8 +36,49 @@ describe('Render Identifier Paragraph renderer', () => { }); describe('render', () => { - it('should delegate rendering to the renderUneditableBranch util', () => { - expect(renderer.render).toBe(renderUneditableBranch); + let context; + let result; + + beforeEach(() => { + const node = { + firstChild: { + type: 'text', + literal: '[Some text]: https://link.com', + next: { + type: 'linebreak', + next: { + type: 'text', + literal: '[identifier]: http://example1.com "title"', + }, + }, + }, + }; + context = { skipChildren: jest.fn() }; + result = renderer.render(node, context); + }); + + it('renders the reference definitions as a code block', () => { + expect(result).toEqual([ + { + type: 'openTag', + tagName: 'pre', + classNames: ['code-block', 'language-markdown'], + attributes: { + 'data-sse-reference-definition': true, + }, + }, + { type: 'openTag', tagName: 'code' }, + { + type: 'text', + content: '[Some text]: https://link.com\n[identifier]: http://example1.com "title"', + }, + { type: 'closeTag', tagName: 'code' }, + { type: 'closeTag', tagName: 'pre' }, + ]); + }); + + it('skips the reference definition node children from rendering', () => { + expect(context.skipChildren).toHaveBeenCalled(); }); }); }); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js deleted file mode 100644 index 7d427108ba6..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list_spec.js +++ /dev/null @@ -1,38 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_list'; -import { renderUneditableBranch } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -import { buildMockTextNode } from './mock_data'; - -const buildMockListNode = literal => { - return { - firstChild: { - firstChild: { - firstChild: buildMockTextNode(literal), - type: 'paragraph', - }, - type: 'item', - }, - type: 'list', - }; -}; - -const normalListNode = buildMockListNode('Just another bullet point'); -const kramdownListNode = buildMockListNode('TOC'); - -describe('Render Kramdown List renderer', () => { - describe('canRender', () => { - it('should return true when the argument is a special kramdown TOC ordered/unordered list', () => { - expect(renderer.canRender(kramdownListNode)).toBe(true); - }); - - it('should return false when the argument is a normal ordered/unordered list', () => { - expect(renderer.canRender(normalListNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should delegate rendering to the renderUneditableBranch util', () => { - expect(renderer.render).toBe(renderUneditableBranch); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js deleted file mode 100644 index 1d2d152ffc3..00000000000 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text_spec.js +++ /dev/null @@ -1,24 +0,0 @@ -import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_kramdown_text'; -import { renderUneditableLeaf } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; - -import { buildMockTextNode, normalTextNode } from './mock_data'; - -const kramdownTextNode = buildMockTextNode('{:toc}'); - -describe('Render Kramdown Text renderer', () => { - describe('canRender', () => { - it('should return true when the argument `literal` has kramdown syntax', () => { - expect(renderer.canRender(kramdownTextNode)).toBe(true); - }); - - it('should return false when the argument `literal` lacks kramdown syntax', () => { - expect(renderer.canRender(normalTextNode)).toBe(false); - }); - }); - - describe('render', () => { - it('should delegate rendering to the renderUneditableLeaf util', () => { - expect(renderer.render).toBe(renderUneditableLeaf); - }); - }); -}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js new file mode 100644 index 00000000000..c1ab700535b --- /dev/null +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_list_item_spec.js @@ -0,0 +1,12 @@ +import renderer from '~/vue_shared/components/rich_content_editor/services/renderers/render_list_item'; +import * as renderUtils from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; + +describe('rich_content_editor/renderers/render_list_item', () => { + it('canRender delegates to renderUtils.willAlwaysRender', () => { + expect(renderer.canRender).toBe(renderUtils.willAlwaysRender); + }); + + it('render delegates to renderUtils.renderWithAttributeDefinitions', () => { + expect(renderer.render).toBe(renderUtils.renderWithAttributeDefinitions); + }); +}); diff --git a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js index 92435b3e4e3..774f830f421 100644 --- a/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js +++ b/spec/frontend/vue_shared/components/rich_content_editor/services/renderers/render_utils_spec.js @@ -1,6 +1,8 @@ import { renderUneditableLeaf, renderUneditableBranch, + renderWithAttributeDefinitions, + willAlwaysRender, } from '~/vue_shared/components/rich_content_editor/services/renderers/render_utils'; import { @@ -8,9 +10,9 @@ import { buildUneditableOpenTokens, } from '~/vue_shared/components/rich_content_editor/services/renderers/build_uneditable_token'; -import { originToken, uneditableCloseToken } from './mock_data'; +import { originToken, uneditableCloseToken, attributeDefinition } from './mock_data'; -describe('Render utils', () => { +describe('rich_content_editor/renderers/render_utils', () => { describe('renderUneditableLeaf', () => { it('should return uneditable block tokens around an origin token', () => { const context = { origin: jest.fn().mockReturnValueOnce(originToken) }; @@ -41,4 +43,68 @@ describe('Render utils', () => { expect(result).toStrictEqual(uneditableCloseToken); }); }); + + describe('willAlwaysRender', () => { + it('always returns true', () => { + expect(willAlwaysRender()).toBe(true); + }); + }); + + describe('renderWithAttributeDefinitions', () => { + let openTagToken; + let closeTagToken; + let node; + const attributes = { + 'data-attribute-definition': attributeDefinition, + }; + + beforeEach(() => { + openTagToken = { type: 'openTag' }; + closeTagToken = { type: 'closeTag' }; + node = { + next: { + firstChild: { + literal: attributeDefinition, + }, + }, + }; + }); + + describe('when token type is openTag', () => { + it('attaches attributes when attributes exist in the node’s next sibling', () => { + const context = { origin: () => openTagToken }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + + it('attaches attributes when attributes exist in the node’s children', () => { + const context = { origin: () => openTagToken }; + node = { + firstChild: { + firstChild: { + next: { + next: { + literal: attributeDefinition, + }, + }, + }, + }, + }; + + expect(renderWithAttributeDefinitions(node, context)).toEqual({ + ...openTagToken, + attributes, + }); + }); + }); + + it('does not attach attributes when token type is "closeTag"', () => { + const context = { origin: () => closeTagToken }; + + expect(renderWithAttributeDefinitions({}, context)).toBe(closeTagToken); + }); + }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js index edec3b138b3..c2091a681f2 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_create_label_spec.js @@ -14,6 +14,7 @@ const createComponent = headerTitle => { }; describe('DropdownCreateLabelComponent', () => { + const colorsCount = Object.keys(mockSuggestedColors).length; let vm; beforeEach(() => { @@ -27,7 +28,7 @@ describe('DropdownCreateLabelComponent', () => { describe('created', () => { it('initializes `suggestedColors` prop on component from `gon.suggested_color_labels` object', () => { - expect(vm.suggestedColors.length).toBe(mockSuggestedColors.length); + expect(vm.suggestedColors.length).toBe(colorsCount); }); }); @@ -37,12 +38,10 @@ describe('DropdownCreateLabelComponent', () => { }); it('renders `Go back` button on component header', () => { - const backButtonEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-back', - ); + const backButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-back'); expect(backButtonEl).not.toBe(null); - expect(backButtonEl.querySelector('.fa-arrow-left')).not.toBe(null); + expect(backButtonEl.querySelector('[data-testid="arrow-left-icon"]')).not.toBe(null); }); it('renders component header element as `Create new label` when `headerTitle` prop is not provided', () => { @@ -61,12 +60,9 @@ describe('DropdownCreateLabelComponent', () => { }); it('renders `Close` button on component header', () => { - const closeButtonEl = vm.$el.querySelector( - '.dropdown-title button.dropdown-title-button.dropdown-menu-close', - ); + const closeButtonEl = vm.$el.querySelector('.dropdown-title .dropdown-menu-close'); expect(closeButtonEl).not.toBe(null); - expect(closeButtonEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBe(null); }); it('renders `Name new label` input element', () => { @@ -78,11 +74,11 @@ describe('DropdownCreateLabelComponent', () => { const colorsListContainerEl = vm.$el.querySelector('.suggest-colors.suggest-colors-dropdown'); expect(colorsListContainerEl).not.toBe(null); - expect(colorsListContainerEl.querySelectorAll('a').length).toBe(mockSuggestedColors.length); + expect(colorsListContainerEl.querySelectorAll('a').length).toBe(colorsCount); const colorItemEl = colorsListContainerEl.querySelectorAll('a')[0]; - expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0]); + expect(colorItemEl.dataset.color).toBe(vm.suggestedColors[0].colorCode); expect(colorItemEl.getAttribute('style')).toBe('background-color: rgb(0, 51, 204);'); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js index 2e721d75b40..0b9a7262e41 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_header_spec.js @@ -33,7 +33,7 @@ describe('DropdownHeaderComponent', () => { ); expect(closeBtnEl).not.toBeNull(); - expect(closeBtnEl.querySelector('.fa-times.dropdown-menu-close-icon')).not.toBeNull(); + expect(closeBtnEl.querySelector('.dropdown-menu-close-icon')).not.toBeNull(); }); }); }); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js index e09f0006359..7847e0ee71d 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/dropdown_value_collapsed_spec.js @@ -87,7 +87,7 @@ describe('DropdownValueCollapsedComponent', () => { }); it('renders tags icon element', () => { - expect(vm.$el.querySelector('.fa-tags')).not.toBeNull(); + expect(vm.$el.querySelector('[data-testid="labels-icon"]')).not.toBeNull(); }); it('renders labels count', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js index 6564c012e67..648ba84fe8f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select/mock_data.js @@ -15,29 +15,29 @@ export const mockLabels = [ }, ]; -export const mockSuggestedColors = [ - '#0033CC', - '#428BCA', - '#44AD8E', - '#A8D695', - '#5CB85C', - '#69D100', - '#004E00', - '#34495E', - '#7F8C8D', - '#A295D6', - '#5843AD', - '#8E44AD', - '#FFECDB', - '#AD4363', - '#D10069', - '#CC0033', - '#FF0000', - '#D9534F', - '#D1D100', - '#F0AD4E', - '#AD8D43', -]; +export const mockSuggestedColors = { + '#0033CC': 'UA blue', + '#428BCA': 'Moderate blue', + '#44AD8E': 'Lime green', + '#A8D695': 'Feijoa', + '#5CB85C': 'Slightly desaturated green', + '#69D100': 'Bright green', + '#004E00': 'Very dark lime green', + '#34495E': 'Very dark desaturated blue', + '#7F8C8D': 'Dark grayish cyan', + '#A295D6': 'Slightly desaturated blue', + '#5843AD': 'Dark moderate blue', + '#8E44AD': 'Dark moderate violet', + '#FFECDB': 'Very pale orange', + '#AD4363': 'Dark moderate pink', + '#D10069': 'Strong pink', + '#CC0033': 'Strong red', + '#FF0000': 'Pure red', + '#D9534F': 'Soft red', + '#D1D100': 'Strong yellow', + '#F0AD4E': 'Soft orange', + '#AD8D43': 'Dark moderate orange', +}; export const mockConfig = { showCreate: true, diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js index cb758797c63..951f706421f 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/dropdown_button_spec.js @@ -62,7 +62,7 @@ describe('DropdownButton', () => { describe('template', () => { it('renders component container element', () => { - expect(wrapper.is('gl-button-stub')).toBe(true); + expect(wrapper.find(GlButton).element).toBe(wrapper.element); }); it('renders default button text element', () => { diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js index a1e0db4d29e..8c17a974b39 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/labels_select_root_spec.js @@ -65,6 +65,33 @@ describe('LabelsSelectRoot', () => { ]), ); }); + + it('calls `handleDropdownClose` with state.labels filterd using `set` prop when dropdown variant is `embedded`', () => { + wrapper = createComponent({ + ...mockConfig, + variant: 'embedded', + }); + + jest.spyOn(wrapper.vm, 'handleDropdownClose').mockImplementation(); + + wrapper.vm.handleVuexActionDispatch( + { type: 'toggleDropdownContents' }, + { + showDropdownButton: false, + showDropdownContents: false, + labels: [{ id: 1 }, { id: 2, set: true }], + }, + ); + + expect(wrapper.vm.handleDropdownClose).toHaveBeenCalledWith( + expect.arrayContaining([ + { + id: 2, + set: true, + }, + ]), + ); + }); }); describe('handleDropdownClose', () => { @@ -123,11 +150,10 @@ describe('LabelsSelectRoot', () => { expect(wrapper.find(DropdownTitle).exists()).toBe(true); }); - it('renders `dropdown-value` component with slot when `showDropdownButton` prop is `false`', () => { + it('renders `dropdown-value` component', () => { const wrapperDropdownValue = createComponent(mockConfig, { default: 'None', }); - wrapperDropdownValue.vm.$store.state.showDropdownButton = false; return wrapperDropdownValue.vm.$nextTick(() => { const valueComp = wrapperDropdownValue.find(DropdownValue); diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js index c742220ba8a..bfb8e263d81 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/actions_spec.js @@ -259,6 +259,21 @@ describe('LabelsSelect Actions', () => { }); }); + describe('replaceSelectedLabels', () => { + it('replaces `state.selectedLabels`', done => { + const selectedLabels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; + + testAction( + actions.replaceSelectedLabels, + selectedLabels, + state, + [{ type: types.REPLACE_SELECTED_LABELS, payload: selectedLabels }], + [], + done, + ); + }); + }); + describe('updateSelectedLabels', () => { it('updates `state.labels` based on provided `labels` param', done => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js index 8081806e314..3414eec8a63 100644 --- a/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js +++ b/spec/frontend/vue_shared/components/sidebar/labels_select_vue/store/mutations_spec.js @@ -152,6 +152,19 @@ describe('LabelsSelect Mutations', () => { }); }); + describe(`${types.REPLACE_SELECTED_LABELS}`, () => { + it('replaces `state.selectedLabels`', () => { + const state = { + selectedLabels: [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }], + }; + const newSelectedLabels = [{ id: 2 }, { id: 5 }]; + + mutations[types.REPLACE_SELECTED_LABELS](state, newSelectedLabels); + + expect(state.selectedLabels).toEqual(newSelectedLabels); + }); + }); + describe(`${types.UPDATE_SELECTED_LABELS}`, () => { const labels = [{ id: 1 }, { id: 2 }, { id: 3 }, { id: 4 }]; diff --git a/spec/frontend/vue_shared/components/table_pagination_spec.js b/spec/frontend/vue_shared/components/table_pagination_spec.js index ef3ae088eec..058dfcdbde2 100644 --- a/spec/frontend/vue_shared/components/table_pagination_spec.js +++ b/spec/frontend/vue_shared/components/table_pagination_spec.js @@ -34,7 +34,7 @@ describe('Pagination component', () => { change: spy, }); - expect(wrapper.isEmpty()).toBe(true); + expect(wrapper.html()).toBe(''); }); it('renders if there is a next page', () => { @@ -50,7 +50,7 @@ describe('Pagination component', () => { change: spy, }); - expect(wrapper.isEmpty()).toBe(false); + expect(wrapper.find(GlPagination).exists()).toBe(true); }); it('renders if there is a prev page', () => { @@ -66,7 +66,7 @@ describe('Pagination component', () => { change: spy, }); - expect(wrapper.isEmpty()).toBe(false); + expect(wrapper.find(GlPagination).exists()).toBe(true); }); }); diff --git a/spec/frontend/vue_shared/components/todo_button_spec.js b/spec/frontend/vue_shared/components/todo_button_spec.js new file mode 100644 index 00000000000..482b5de11f6 --- /dev/null +++ b/spec/frontend/vue_shared/components/todo_button_spec.js @@ -0,0 +1,48 @@ +import { shallowMount, mount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import TodoButton from '~/vue_shared/components/todo_button.vue'; + +describe('Todo Button', () => { + let wrapper; + + const createComponent = (props = {}, mountFn = shallowMount) => { + wrapper = mountFn(TodoButton, { + propsData: { + ...props, + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + }); + + it('renders GlButton', () => { + createComponent(); + + expect(wrapper.find(GlButton).exists()).toBe(true); + }); + + it('emits click event when clicked', () => { + createComponent({}, mount); + wrapper.find(GlButton).trigger('click'); + + expect(wrapper.emitted().click).toBeTruthy(); + }); + + it.each` + label | isTodo + ${'Mark as done'} | ${true} + ${'Add a To-Do'} | ${false} + `('sets correct label when isTodo is $isTodo', ({ label, isTodo }) => { + createComponent({ isTodo }); + + expect(wrapper.find(GlButton).text()).toBe(label); + }); + + it('binds additional props to GlButton', () => { + createComponent({ loading: true }); + + expect(wrapper.find(GlButton).props('loading')).toBe(true); + }); +}); diff --git a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js index 902e83da7be..84e7a6a162e 100644 --- a/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js +++ b/spec/frontend/vue_shared/components/user_avatar/user_avatar_link_spec.js @@ -38,10 +38,6 @@ describe('User Avatar Link Component', () => { wrapper = null; }); - it('should return a defined Vue component', () => { - expect(wrapper.isVueInstance()).toBe(true); - }); - it('should have user-avatar-image registered as child component', () => { expect(wrapper.vm.$options.components.userAvatarImage).toBeDefined(); }); diff --git a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js index a4ff6ac0c16..b43bb6b10e0 100644 --- a/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js +++ b/spec/frontend/vue_shared/components/user_popover/user_popover_spec.js @@ -1,7 +1,6 @@ -import { GlSkeletonLoading, GlSprintf } from '@gitlab/ui'; +import { GlDeprecatedSkeletonLoading as GlSkeletonLoading, GlSprintf, GlIcon } from '@gitlab/ui'; import { shallowMount } from '@vue/test-utils'; import UserPopover from '~/vue_shared/components/user_popover/user_popover.vue'; -import Icon from '~/vue_shared/components/icon.vue'; const DEFAULT_PROPS = { user: { @@ -74,7 +73,7 @@ describe('User Popover Component', () => { }); it('shows icon for location', () => { - const iconEl = wrapper.find(Icon); + const iconEl = wrapper.find(GlIcon); expect(iconEl.props('name')).toEqual('location'); }); @@ -139,9 +138,9 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.findAll(Icon).filter(icon => icon.props('name') === 'profile').length).toEqual( - 1, - ); + expect( + wrapper.findAll(GlIcon).filter(icon => icon.props('name') === 'profile').length, + ).toEqual(1); }); it('shows icon for work information', () => { @@ -152,7 +151,9 @@ describe('User Popover Component', () => { createWrapper({ user }); - expect(wrapper.findAll(Icon).filter(icon => icon.props('name') === 'work').length).toEqual(1); + expect(wrapper.findAll(GlIcon).filter(icon => icon.props('name') === 'work').length).toEqual( + 1, + ); }); }); diff --git a/spec/frontend/vue_shared/components/web_ide_link_spec.js b/spec/frontend/vue_shared/components/web_ide_link_spec.js new file mode 100644 index 00000000000..57f511903d9 --- /dev/null +++ b/spec/frontend/vue_shared/components/web_ide_link_spec.js @@ -0,0 +1,106 @@ +import { shallowMount } from '@vue/test-utils'; +import LocalStorageSync from '~/vue_shared/components/local_storage_sync.vue'; +import WebIdeLink from '~/vue_shared/components/web_ide_link.vue'; +import ActionsButton from '~/vue_shared/components/actions_button.vue'; + +const TEST_WEB_IDE_URL = '/-/ide/project/gitlab-test/test/edit/master/-/'; +const TEST_GITPOD_URL = 'https://gitpod.test/'; + +const ACTION_WEB_IDE = { + href: TEST_WEB_IDE_URL, + key: 'webide', + secondaryText: 'Quickly and easily edit multiple files in your project.', + tooltip: '', + text: 'Web IDE', + attrs: { + 'data-qa-selector': 'web_ide_button', + }, +}; +const ACTION_WEB_IDE_FORK = { + ...ACTION_WEB_IDE, + href: '#modal-confirm-fork', + handle: expect.any(Function), +}; +const ACTION_GITPOD = { + href: TEST_GITPOD_URL, + key: 'gitpod', + secondaryText: 'Launch a ready-to-code development environment for your project.', + tooltip: 'Launch a ready-to-code development environment for your project.', + text: 'Gitpod', + attrs: { + 'data-qa-selector': 'gitpod_button', + }, +}; +const ACTION_GITPOD_ENABLE = { + ...ACTION_GITPOD, + href: '#modal-enable-gitpod', + handle: expect.any(Function), +}; + +describe('Web IDE link component', () => { + let wrapper; + + function createComponent(props) { + wrapper = shallowMount(WebIdeLink, { + propsData: { + webIdeUrl: TEST_WEB_IDE_URL, + gitpodUrl: TEST_GITPOD_URL, + ...props, + }, + }); + } + + afterEach(() => { + wrapper.destroy(); + }); + + const findActionsButton = () => wrapper.find(ActionsButton); + const findLocalStorageSync = () => wrapper.find(LocalStorageSync); + + it.each` + props | expectedActions + ${{}} | ${[ACTION_WEB_IDE]} + ${{ needsToFork: true }} | ${[ACTION_WEB_IDE_FORK]} + ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: true }} | ${[ACTION_GITPOD]} + ${{ showWebIdeButton: false, showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_GITPOD_ENABLE]} + ${{ showGitpodButton: true, gitpodEnabled: false }} | ${[ACTION_WEB_IDE, ACTION_GITPOD_ENABLE]} + `('renders actions with props=$props', ({ props, expectedActions }) => { + createComponent(props); + + expect(findActionsButton().props('actions')).toEqual(expectedActions); + }); + + describe('with multiple actions', () => { + beforeEach(() => { + createComponent({ showWebIdeButton: true, showGitpodButton: true, gitpodEnabled: true }); + }); + + it('selected Web IDE by default', () => { + expect(findActionsButton().props()).toMatchObject({ + actions: [ACTION_WEB_IDE, ACTION_GITPOD], + selectedKey: ACTION_WEB_IDE.key, + }); + }); + + it('should set selection with local storage value', async () => { + expect(findActionsButton().props('selectedKey')).toBe(ACTION_WEB_IDE.key); + + findLocalStorageSync().vm.$emit('input', ACTION_GITPOD.key); + + await wrapper.vm.$nextTick(); + + expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key); + }); + + it('should update local storage when selection changes', async () => { + expect(findLocalStorageSync().props('value')).toBe(ACTION_WEB_IDE.key); + + findActionsButton().vm.$emit('select', ACTION_GITPOD.key); + + await wrapper.vm.$nextTick(); + + expect(findActionsButton().props('selectedKey')).toBe(ACTION_GITPOD.key); + expect(findLocalStorageSync().props('value')).toBe(ACTION_GITPOD.key); + }); + }); +}); |