diff options
Diffstat (limited to 'spec/frontend/registry')
18 files changed, 964 insertions, 480 deletions
diff --git a/spec/frontend/registry/explorer/components/delete_button_spec.js b/spec/frontend/registry/explorer/components/delete_button_spec.js new file mode 100644 index 00000000000..bb0fe81117a --- /dev/null +++ b/spec/frontend/registry/explorer/components/delete_button_spec.js @@ -0,0 +1,73 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; +import component from '~/registry/explorer/components/delete_button.vue'; + +describe('delete_button', () => { + let wrapper; + + const defaultProps = { + title: 'Foo title', + tooltipTitle: 'Bar tooltipTitle', + }; + + const findButton = () => wrapper.find(GlButton); + + const mountComponent = props => { + wrapper = shallowMount(component, { + propsData: { + ...defaultProps, + ...props, + }, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('tooltip', () => { + it('the title is controlled by tooltipTitle prop', () => { + mountComponent(); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip).toBeDefined(); + expect(tooltip.value.title).toBe(defaultProps.tooltipTitle); + }); + + it('is disabled when tooltipTitle is disabled', () => { + mountComponent({ tooltipDisabled: true }); + const tooltip = getBinding(wrapper.element, 'gl-tooltip'); + expect(tooltip.value.disabled).toBe(true); + }); + + describe('button', () => { + it('exists', () => { + mountComponent(); + expect(findButton().exists()).toBe(true); + }); + + it('has the correct props/attributes bound', () => { + mountComponent({ disabled: true }); + expect(findButton().attributes()).toMatchObject({ + 'aria-label': 'Foo title', + category: 'secondary', + icon: 'remove', + title: 'Foo title', + variant: 'danger', + disabled: 'true', + }); + }); + + it('emits a delete event', () => { + mountComponent(); + expect(wrapper.emitted('delete')).toEqual(undefined); + findButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/details_row_spec.js b/spec/frontend/registry/explorer/components/details_page/details_row_spec.js new file mode 100644 index 00000000000..95b8e18d677 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/details_row_spec.js @@ -0,0 +1,43 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlIcon } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/details_row.vue'; + +describe('DetailsRow', () => { + let wrapper; + + const findIcon = () => wrapper.find(GlIcon); + const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]'); + + const mountComponent = () => { + wrapper = shallowMount(component, { + propsData: { + icon: 'clock', + }, + slots: { + default: '<div data-testid="default-slot"></div>', + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + it('contains an icon', () => { + mountComponent(); + expect(findIcon().exists()).toBe(true); + }); + + it('icon has the correct props', () => { + mountComponent(); + expect(findIcon().props()).toMatchObject({ + name: 'clock', + }); + }); + + it('has a default slot', () => { + mountComponent(); + expect(findDefaultSlot().exists()).toBe(true); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js b/spec/frontend/registry/explorer/components/details_page/empty_tags_state_spec.js index da80c75a26a..09afd9d2d84 100644 --- a/spec/frontend/registry/explorer/components/details_page/empty_tags_state.js +++ b/spec/frontend/registry/explorer/components/details_page/empty_tags_state_spec.js @@ -29,7 +29,7 @@ describe('EmptyTagsState component', () => { it('contains gl-empty-state', () => { mountComponent(); - expect(findEmptyState().exist()).toBe(true); + expect(findEmptyState().exists()).toBe(true); }); it('has the correct props', () => { diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js new file mode 100644 index 00000000000..9e876d6d8a3 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_row_spec.js @@ -0,0 +1,330 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlFormCheckbox, GlSprintf, GlIcon } from '@gitlab/ui'; + +import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; +import component from '~/registry/explorer/components/details_page/tags_list_row.vue'; +import DeleteButton from '~/registry/explorer/components/delete_button.vue'; +import DetailsRow from '~/registry/explorer/components/details_page/details_row.vue'; +import { + REMOVE_TAG_BUTTON_TITLE, + REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + MISSING_MANIFEST_WARNING_TOOLTIP, + NOT_AVAILABLE_TEXT, + NOT_AVAILABLE_SIZE, +} from '~/registry/explorer/constants/index'; +import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; + +import { tagsListResponse } from '../../mock_data'; +import { ListItem } from '../../stubs'; + +describe('tags list row', () => { + let wrapper; + const [tag] = [...tagsListResponse.data]; + + const defaultProps = { tag, isDesktop: true, index: 0 }; + + const findCheckbox = () => wrapper.find(GlFormCheckbox); + const findName = () => wrapper.find('[data-testid="name"]'); + const findSize = () => wrapper.find('[data-testid="size"]'); + const findTime = () => wrapper.find('[data-testid="time"]'); + const findShortRevision = () => wrapper.find('[data-testid="digest"]'); + const findClipboardButton = () => wrapper.find(ClipboardButton); + const findDeleteButton = () => wrapper.find(DeleteButton); + const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip); + const findDetailsRows = () => wrapper.findAll(DetailsRow); + const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]'); + const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]'); + const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]'); + const findWarningIcon = () => wrapper.find(GlIcon); + + const mountComponent = (propsData = defaultProps) => { + wrapper = shallowMount(component, { + stubs: { + GlSprintf, + ListItem, + DetailsRow, + }, + propsData, + directives: { + GlTooltip: createMockDirective(), + }, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('checkbox', () => { + it('exists', () => { + mountComponent(); + + expect(findCheckbox().exists()).toBe(true); + }); + + it("does not exist when the row can't be deleted", () => { + const customTag = { ...tag, destroy_path: '' }; + + mountComponent({ ...defaultProps, tag: customTag }); + + expect(findCheckbox().exists()).toBe(false); + }); + + it('is disabled when the digest is missing', () => { + mountComponent({ tag: { ...tag, digest: null } }); + expect(findCheckbox().attributes('disabled')).toBe('true'); + }); + + it('is wired to the selected prop', () => { + mountComponent({ ...defaultProps, selected: true }); + + expect(findCheckbox().attributes('checked')).toBe('true'); + }); + + it('when changed emit a select event', () => { + mountComponent(); + + findCheckbox().vm.$emit('change'); + + expect(wrapper.emitted('select')).toEqual([[]]); + }); + }); + + describe('tag name', () => { + it('exists', () => { + mountComponent(); + + expect(findName().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findName().text()).toBe(tag.name); + }); + + it('has a tooltip', () => { + mountComponent(); + + const tooltip = getBinding(findName().element, 'gl-tooltip'); + + expect(tooltip.value.title).toBe(tag.name); + }); + + it('on mobile has mw-s class', () => { + mountComponent({ ...defaultProps, isDesktop: false }); + + expect(findName().classes('mw-s')).toBe(true); + }); + }); + + describe('clipboard button', () => { + it('exist if tag.location exist', () => { + mountComponent(); + + expect(findClipboardButton().exists()).toBe(true); + }); + + it('is hidden if tag does not have a location', () => { + mountComponent({ ...defaultProps, tag: { ...tag, location: null } }); + + expect(findClipboardButton().exists()).toBe(false); + }); + + it('has the correct props/attributes', () => { + mountComponent(); + + expect(findClipboardButton().attributes()).toMatchObject({ + text: 'location', + title: 'location', + }); + }); + }); + + describe('warning icon', () => { + it('is normally hidden', () => { + mountComponent(); + + expect(findWarningIcon().exists()).toBe(false); + }); + + it('is shown when the tag is broken', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findWarningIcon().exists()).toBe(true); + }); + + it('has an appropriate tooltip', () => { + mountComponent({ tag: { ...tag, digest: null } }); + + const tooltip = getBinding(findWarningIcon().element, 'gl-tooltip'); + expect(tooltip.value.title).toBe(MISSING_MANIFEST_WARNING_TOOLTIP); + }); + }); + + describe('size', () => { + it('exists', () => { + mountComponent(); + + expect(findSize().exists()).toBe(true); + }); + + it('contains the total_size and layers', () => { + mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); + }); + + it('when total_size is missing', () => { + mountComponent(); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`); + }); + + it('when layers are missing', () => { + mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } }); + + expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); + }); + + it('when there is 1 layer', () => { + mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } }); + + expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 1 layer`); + }); + }); + + describe('time', () => { + it('exists', () => { + mountComponent(); + + expect(findTime().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findTime().text()).toBe('Published'); + }); + + it('contains time_ago_tooltip component', () => { + mountComponent(); + + expect(findTimeAgoTooltip().exists()).toBe(true); + }); + + it('pass the correct props to time ago tooltip', () => { + mountComponent(); + + expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.created_at }); + }); + }); + + describe('digest', () => { + it('exists', () => { + mountComponent(); + + expect(findShortRevision().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 1ab51d5'); + }); + + it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => { + mountComponent({ tag: { ...tag, digest: null } }); + + expect(findShortRevision().text()).toMatchInterpolatedText(`Digest: ${NOT_AVAILABLE_TEXT}`); + }); + }); + + describe('delete button', () => { + it('exists', () => { + mountComponent(); + + expect(findDeleteButton().exists()).toBe(true); + }); + + it('has the correct props/attributes', () => { + mountComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + title: REMOVE_TAG_BUTTON_TITLE, + tooltiptitle: REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, + tooltipdisabled: 'true', + }); + }); + + it.each` + destroy_path | digest + ${'foo'} | ${null} + ${null} | ${'foo'} + ${null} | ${null} + `( + 'is disabled when destroy_path is $destroy_path and digest is $digest', + ({ destroy_path, digest }) => { + mountComponent({ ...defaultProps, tag: { ...tag, destroy_path, digest } }); + + expect(findDeleteButton().attributes('disabled')).toBe('true'); + }, + ); + + it('delete event emits delete', () => { + mountComponent(); + + findDeleteButton().vm.$emit('delete'); + + expect(wrapper.emitted('delete')).toEqual([[]]); + }); + }); + + describe('details rows', () => { + describe('when the tag has a digest', () => { + beforeEach(() => { + mountComponent(); + + return wrapper.vm.$nextTick(); + }); + + it('has 3 details rows', () => { + expect(findDetailsRows().length).toBe(3); + }); + + describe.each` + name | finderFunction | text | icon | clipboard + ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the bar image repository at 10:23 GMT+0000 on 2020-06-29'} | ${'clock'} | ${false} + ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c'} | ${'log'} | ${true} + ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43'} | ${'cloud-gear'} | ${true} + `('$name details row', ({ finderFunction, text, icon, clipboard }) => { + it(`has ${text} as text`, () => { + expect(finderFunction().text()).toMatchInterpolatedText(text); + }); + + it(`has the ${icon} icon`, () => { + expect(finderFunction().props('icon')).toBe(icon); + }); + + it(`is ${clipboard} that clipboard button exist`, () => { + expect( + finderFunction() + .find(ClipboardButton) + .exists(), + ).toBe(clipboard); + }); + }); + }); + + describe('when the tag does not have a digest', () => { + it('hides the details rows', async () => { + mountComponent({ tag: { ...tag, digest: null } }); + + await wrapper.vm.$nextTick(); + expect(findDetailsRows().length).toBe(0); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js new file mode 100644 index 00000000000..1f560753476 --- /dev/null +++ b/spec/frontend/registry/explorer/components/details_page/tags_list_spec.js @@ -0,0 +1,146 @@ +import { shallowMount } from '@vue/test-utils'; +import { GlButton } from '@gitlab/ui'; +import component from '~/registry/explorer/components/details_page/tags_list.vue'; +import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue'; +import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index'; +import { tagsListResponse } from '../../mock_data'; + +describe('Tags List', () => { + let wrapper; + const tags = [...tagsListResponse.data]; + const readOnlyTags = tags.map(t => ({ ...t, destroy_path: undefined })); + + const findTagsListRow = () => wrapper.findAll(TagsListRow); + const findDeleteButton = () => wrapper.find(GlButton); + const findListTitle = () => wrapper.find('[data-testid="list-title"]'); + + const mountComponent = (propsData = { tags, isDesktop: true }) => { + wrapper = shallowMount(component, { + propsData, + }); + }; + + afterEach(() => { + wrapper.destroy(); + wrapper = null; + }); + + describe('List title', () => { + it('exists', () => { + mountComponent(); + + expect(findListTitle().exists()).toBe(true); + }); + + it('has the correct text', () => { + mountComponent(); + + expect(findListTitle().text()).toBe(TAGS_LIST_TITLE); + }); + }); + + describe('delete button', () => { + it.each` + inputTags | isDesktop | isVisible + ${tags} | ${true} | ${true} + ${tags} | ${false} | ${false} + ${readOnlyTags} | ${true} | ${false} + ${readOnlyTags} | ${false} | ${false} + `( + 'is $isVisible that delete button exists when tags is $inputTags and isDesktop is $isDesktop', + ({ inputTags, isDesktop, isVisible }) => { + mountComponent({ tags: inputTags, isDesktop }); + + expect(findDeleteButton().exists()).toBe(isVisible); + }, + ); + + it('has the correct text', () => { + mountComponent(); + + expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE); + }); + + it('has the correct props', () => { + mountComponent(); + + expect(findDeleteButton().attributes()).toMatchObject({ + category: 'secondary', + variant: 'danger', + }); + }); + + it('is disabled when no item is selected', () => { + mountComponent(); + + expect(findDeleteButton().attributes('disabled')).toBe('true'); + }); + + it('is enabled when at least one item is selected', async () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('select'); + await wrapper.vm.$nextTick(); + expect(findDeleteButton().attributes('disabled')).toBe(undefined); + }); + + it('click event emits a deleted event with selected items', () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('select'); + + findDeleteButton().vm.$emit('click'); + expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]); + }); + }); + + describe('list rows', () => { + it('one row exist for each tag', () => { + mountComponent(); + + expect(findTagsListRow()).toHaveLength(tags.length); + }); + + it('the correct props are bound to it', () => { + mountComponent(); + + const rows = findTagsListRow(); + + expect(rows.at(0).attributes()).toMatchObject({ + first: 'true', + isdesktop: 'true', + }); + + // The list has only two tags and for some reasons .at(-1) does not work + expect(rows.at(1).attributes()).toMatchObject({ + last: 'true', + isdesktop: 'true', + }); + }); + + describe('events', () => { + it('select event update the selected items', async () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('select'); + await wrapper.vm.$nextTick(); + expect( + findTagsListRow() + .at(0) + .attributes('selected'), + ).toBe('true'); + }); + + it('delete event emit a delete event', () => { + mountComponent(); + findTagsListRow() + .at(0) + .vm.$emit('delete'); + expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]); + }); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js b/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js deleted file mode 100644 index a60a362dcfe..00000000000 --- a/spec/frontend/registry/explorer/components/details_page/tags_table_spec.js +++ /dev/null @@ -1,286 +0,0 @@ -import { mount } from '@vue/test-utils'; -import stubChildren from 'helpers/stub_children'; -import component from '~/registry/explorer/components/details_page/tags_table.vue'; -import { tagsListResponse } from '../../mock_data'; - -describe('tags_table', () => { - let wrapper; - const tags = [...tagsListResponse.data]; - - const findMainCheckbox = () => wrapper.find('[data-testid="mainCheckbox"]'); - const findFirstRowItem = testid => wrapper.find(`[data-testid="${testid}"]`); - const findBulkDeleteButton = () => wrapper.find('[data-testid="bulkDeleteButton"]'); - const findAllDeleteButtons = () => wrapper.findAll('[data-testid="singleDeleteButton"]'); - const findAllCheckboxes = () => wrapper.findAll('[data-testid="rowCheckbox"]'); - const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked')); - const findFirsTagColumn = () => wrapper.find('.js-tag-column'); - const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]'); - - const findLoaderSlot = () => wrapper.find('[data-testid="loaderSlot"]'); - const findEmptySlot = () => wrapper.find('[data-testid="emptySlot"]'); - - const mountComponent = (propsData = { tags, isDesktop: true }) => { - wrapper = mount(component, { - stubs: { - ...stubChildren(component), - GlTable: false, - }, - propsData, - slots: { - loader: '<div data-testid="loaderSlot"></div>', - empty: '<div data-testid="emptySlot"></div>', - }, - }); - }; - - afterEach(() => { - wrapper.destroy(); - wrapper = null; - }); - - it.each([ - 'rowCheckbox', - 'rowName', - 'rowShortRevision', - 'rowSize', - 'rowTime', - 'singleDeleteButton', - ])('%s exist in the table', element => { - mountComponent(); - - expect(findFirstRowItem(element).exists()).toBe(true); - }); - - describe('header checkbox', () => { - it('exists', () => { - mountComponent(); - expect(findMainCheckbox().exists()).toBe(true); - }); - - it('if selected selects all the rows', () => { - mountComponent(); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findMainCheckbox().attributes('checked')).toBeTruthy(); - expect(findCheckedCheckboxes()).toHaveLength(tags.length); - }); - }); - - it('if deselect deselects all the row', () => { - mountComponent(); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm - .$nextTick() - .then(() => { - expect(findMainCheckbox().attributes('checked')).toBeTruthy(); - findMainCheckbox().vm.$emit('change'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(findMainCheckbox().attributes('checked')).toBe(undefined); - expect(findCheckedCheckboxes()).toHaveLength(0); - }); - }); - }); - - describe('row checkbox', () => { - beforeEach(() => { - mountComponent(); - }); - - it('selecting and deselecting the checkbox works as intended', () => { - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm - .$nextTick() - .then(() => { - expect(wrapper.vm.selectedItems).toEqual([tags[0].name]); - expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy(); - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm.$nextTick(); - }) - .then(() => { - expect(wrapper.vm.selectedItems.length).toBe(0); - expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined); - }); - }); - }); - - describe('header delete button', () => { - beforeEach(() => { - mountComponent(); - }); - - it('exists', () => { - expect(findBulkDeleteButton().exists()).toBe(true); - }); - - it('is disabled if no item is selected', () => { - expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); - }); - - it('is enabled if at least one item is selected', () => { - expect(findBulkDeleteButton().attributes('disabled')).toBe('true'); - findFirstRowItem('rowCheckbox').vm.$emit('change'); - return wrapper.vm.$nextTick().then(() => { - expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy(); - }); - }); - - describe('on click', () => { - it('when one item is selected', () => { - findFirstRowItem('rowCheckbox').vm.$emit('change'); - findBulkDeleteButton().vm.$emit('click'); - expect(wrapper.emitted('delete')).toEqual([[['centos6']]]); - }); - - it('when multiple items are selected', () => { - findMainCheckbox().vm.$emit('change'); - findBulkDeleteButton().vm.$emit('click'); - - expect(wrapper.emitted('delete')).toEqual([[tags.map(t => t.name)]]); - }); - }); - }); - - describe('row delete button', () => { - beforeEach(() => { - mountComponent(); - }); - - it('exists', () => { - expect( - findAllDeleteButtons() - .at(0) - .exists(), - ).toBe(true); - }); - - it('is disabled if the item has no destroy_path', () => { - expect( - findAllDeleteButtons() - .at(1) - .attributes('disabled'), - ).toBe('true'); - }); - - it('on click', () => { - findAllDeleteButtons() - .at(0) - .vm.$emit('click'); - - expect(wrapper.emitted('delete')).toEqual([[['centos6']]]); - }); - }); - - describe('name cell', () => { - it('tag column has a tooltip with the tag name', () => { - mountComponent(); - expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name); - }); - - describe('on desktop viewport', () => { - beforeEach(() => { - mountComponent(); - }); - - it('table header has class w-25', () => { - expect(findFirsTagColumn().classes()).toContain('w-25'); - }); - - it('tag column has the mw-m class', () => { - expect(findFirstRowItem('rowName').classes()).toContain('mw-m'); - }); - }); - - describe('on mobile viewport', () => { - beforeEach(() => { - mountComponent({ tags, isDesktop: false }); - }); - - it('table header does not have class w-25', () => { - expect(findFirsTagColumn().classes()).not.toContain('w-25'); - }); - - it('tag column has the gl-justify-content-end class', () => { - expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end'); - }); - }); - }); - - describe('last updated cell', () => { - let timeCell; - - beforeEach(() => { - mountComponent(); - timeCell = findFirstRowItem('rowTime'); - }); - - it('displays the time in string format', () => { - expect(timeCell.text()).toBe('2 years ago'); - }); - - it('has a tooltip timestamp', () => { - expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000'); - }); - }); - - describe('empty state slot', () => { - describe('when the table is empty', () => { - beforeEach(() => { - mountComponent({ tags: [], isDesktop: true }); - }); - - it('does not show table rows', () => { - expect(findFirstTagNameText().exists()).toBe(false); - }); - - it('has the empty state slot', () => { - expect(findEmptySlot().exists()).toBe(true); - }); - }); - - describe('when the table is not empty', () => { - beforeEach(() => { - mountComponent({ tags, isDesktop: true }); - }); - - it('does show table rows', () => { - expect(findFirstTagNameText().exists()).toBe(true); - }); - - it('does not show the empty state', () => { - expect(findEmptySlot().exists()).toBe(false); - }); - }); - }); - - describe('loader slot', () => { - describe('when the data is loading', () => { - beforeEach(() => { - mountComponent({ isLoading: true, tags }); - }); - - it('show the loader', () => { - expect(findLoaderSlot().exists()).toBe(true); - }); - - it('does not show the table rows', () => { - expect(findFirstTagNameText().exists()).toBe(false); - }); - }); - - describe('when the data is not loading', () => { - beforeEach(() => { - mountComponent({ isLoading: false, tags }); - }); - - it('does not show the loader', () => { - expect(findLoaderSlot().exists()).toBe(false); - }); - - it('shows the table rows', () => { - expect(findFirstTagNameText().exists()).toBe(true); - }); - }); - }); -}); diff --git a/spec/frontend/registry/explorer/components/list_item_spec.js b/spec/frontend/registry/explorer/components/list_item_spec.js new file mode 100644 index 00000000000..f244627a8c3 --- /dev/null +++ b/spec/frontend/registry/explorer/components/list_item_spec.js @@ -0,0 +1,156 @@ +import { GlButton } from '@gitlab/ui'; +import { shallowMount } from '@vue/test-utils'; +import component from '~/registry/explorer/components/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; + }); + + it.each` + slotName | finderFunction + ${'left-primary'} | ${findLeftPrimarySlot} + ${'left-secondary'} | ${findLeftSecondarySlot} + ${'right-primary'} | ${findRightPrimarySlot} + ${'right-secondary'} | ${findRightSecondarySlot} + ${'left-action'} | ${findLeftActionSlot} + ${'right-action'} | ${findRightActionSlot} + `('has a $slotName slot', ({ finderFunction }) => { + mountComponent(); + + expect(finderFunction().exists()).toBe(true); + }); + + 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('first prop', () => { + it('when is true displays a double top border', () => { + mountComponent({ first: true }); + + expect(wrapper.classes('gl-border-t-2')).toBe(true); + }); + + it('when is false display a single top border', () => { + mountComponent({ first: false }); + + expect(wrapper.classes('gl-border-t-1')).toBe(true); + }); + }); + + describe('last prop', () => { + it('when is true displays a double bottom border', () => { + mountComponent({ last: true }); + + expect(wrapper.classes('gl-border-b-2')).toBe(true); + }); + + it('when is false display a single bottom border', () => { + mountComponent({ last: false }); + + expect(wrapper.classes('gl-border-b-1')).toBe(true); + }); + }); + + describe('selected prop', () => { + it('when true applies the selected border and background', () => { + mountComponent({ selected: true }); + + expect(wrapper.classes()).toEqual( + expect.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']), + ); + expect(wrapper.classes()).toEqual(expect.not.arrayContaining(['gl-border-gray-100'])); + }); + + it('when false applies the default border', () => { + mountComponent({ selected: false }); + + expect(wrapper.classes()).toEqual( + expect.not.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']), + ); + expect(wrapper.classes()).toEqual(expect.arrayContaining(['gl-border-gray-100'])); + }); + }); +}); diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap index 3761369c944..a8412e2bde9 100644 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap @@ -2,13 +2,10 @@ exports[`Registry Group Empty state to match the default snapshot 1`] = ` <div - class="container-message" svg-path="foo" title="There are no container images available in this group" > - <p - class="js-no-container-images-text" - > + <p> With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. <gl-link-stub href="baz" diff --git a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap index d8ec9c3ca4d..8413e17c7b2 100644 --- a/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap +++ b/spec/frontend/registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap @@ -2,13 +2,10 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` <div - class="container-message" svg-path="bazFoo" title="There are no container images stored for this project" > - <p - class="js-no-container-images-text" - > + <p> With the Container Registry, every project can have its own space to store its Docker images. <gl-link-stub href="baz" @@ -22,9 +19,7 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` CLI Commands </h5> - <p - class="js-not-logged-in-to-registry-text" - > + <p> If you are not already logged in, you need to authenticate to the Container Registry by using your GitLab username and password. If you have <gl-link-stub href="barBaz" @@ -42,78 +37,50 @@ exports[`Registry Project Empty state to match the default snapshot 1`] = ` instead of a password. </p> - <div - class="input-group append-bottom-10" + <gl-form-input-group-stub + class="gl-mb-4" + predefinedoptions="[object Object]" + value="" > - <input - class="form-control monospace" - readonly="readonly" + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" type="text" + value="docker login bar" /> - - <span - class="input-group-append" - > - <clipboard-button-stub - class="input-group-text" - cssclass="btn-default" - text="docker login bar" - title="Copy login command" - tooltipplacement="top" - /> - </span> - </div> + </gl-form-input-group-stub> - <p /> - - <p> + <p + class="gl-mb-4" + > You can add an image to this registry with the following commands: </p> - <div - class="input-group append-bottom-10" + <gl-form-input-group-stub + class="gl-mb-4 " + predefinedoptions="[object Object]" + value="" > - <input - class="form-control monospace" - readonly="readonly" + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" type="text" + value="docker build -t foo ." /> - - <span - class="input-group-append" - > - <clipboard-button-stub - class="input-group-text" - cssclass="btn-default" - text="docker build -t foo ." - title="Copy build command" - tooltipplacement="top" - /> - </span> - </div> + </gl-form-input-group-stub> - <div - class="input-group" + <gl-form-input-group-stub + predefinedoptions="[object Object]" + value="" > - <input - class="form-control monospace" - readonly="readonly" + <gl-form-input-stub + class="gl-font-monospace!" + readonly="" type="text" + value="docker push foo" /> - - <span - class="input-group-append" - > - <clipboard-button-stub - class="input-group-text" - cssclass="btn-default" - text="docker push foo" - title="Copy push command" - tooltipplacement="top" - /> - </span> - </div> + </gl-form-input-group-stub> </div> `; diff --git a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js index 78de35ae1dc..aaeaaf00748 100644 --- a/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js +++ b/spec/frontend/registry/explorer/components/list_page/image_list_row_spec.js @@ -1,11 +1,14 @@ import { shallowMount } from '@vue/test-utils'; import { GlIcon, GlSprintf } from '@gitlab/ui'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; -import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; +import Component from '~/registry/explorer/components/list_page/image_list_row.vue'; +import ListItem from '~/registry/explorer/components/list_item.vue'; +import DeleteButton from '~/registry/explorer/components/delete_button.vue'; import { ROW_SCHEDULED_FOR_DELETION, LIST_DELETE_BUTTON_DISABLED, + REMOVE_REPOSITORY_LABEL, } from '~/registry/explorer/constants'; import { RouterLink } from '../../stubs'; import { imagesListResponse } from '../../mock_data'; @@ -13,10 +16,10 @@ import { imagesListResponse } from '../../mock_data'; describe('Image List Row', () => { let wrapper; const item = imagesListResponse.data[0]; - const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]'); + const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]'); const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]'); - const findDeleteButtonWrapper = () => wrapper.find('[data-testid="deleteButtonWrapper"]'); + const findDeleteBtn = () => wrapper.find(DeleteButton); const findClipboardButton = () => wrapper.find(ClipboardButton); const mountComponent = props => { @@ -24,6 +27,7 @@ describe('Image List Row', () => { stubs: { RouterLink, GlSprintf, + ListItem, }, propsData: { item, @@ -72,29 +76,24 @@ describe('Image List Row', () => { }); }); - describe('delete button wrapper', () => { - it('has a tooltip', () => { - mountComponent(); - const tooltip = getBinding(findDeleteButtonWrapper().element, 'gl-tooltip'); - expect(tooltip).toBeDefined(); - expect(tooltip.value.title).toBe(LIST_DELETE_BUTTON_DISABLED); - }); - it('tooltip is enabled when destroy_path is falsy', () => { - mountComponent({ item: { ...item, destroy_path: null } }); - const tooltip = getBinding(findDeleteButtonWrapper().element, 'gl-tooltip'); - expect(tooltip.value.disabled).toBeFalsy(); - }); - }); - describe('delete button', () => { it('exists', () => { mountComponent(); expect(findDeleteBtn().exists()).toBe(true); }); + it('has the correct props', () => { + mountComponent(); + expect(findDeleteBtn().attributes()).toMatchObject({ + title: REMOVE_REPOSITORY_LABEL, + tooltipdisabled: `${Boolean(item.destroy_path)}`, + tooltiptitle: LIST_DELETE_BUTTON_DISABLED, + }); + }); + it('emits a delete event', () => { mountComponent(); - findDeleteBtn().vm.$emit('click'); + findDeleteBtn().vm.$emit('delete'); expect(wrapper.emitted('delete')).toEqual([[item]]); }); diff --git a/spec/frontend/registry/explorer/mock_data.js b/spec/frontend/registry/explorer/mock_data.js index e2b33826503..a7ffed4c9fd 100644 --- a/spec/frontend/registry/explorer/mock_data.js +++ b/spec/frontend/registry/explorer/mock_data.js @@ -70,9 +70,10 @@ export const tagsListResponse = { size: 19, layers: 10, location: 'location', - path: 'bar', - created_at: 1505828744434, + path: 'bar:centos6', + created_at: '2020-06-29T10:23:51.766+00:00', destroy_path: 'path', + digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c', }, { name: 'test-tag', @@ -80,9 +81,10 @@ export const tagsListResponse = { short_revision: 'b969de599', size: 19, layers: 10, - path: 'foo', + path: 'foo:test-tag', location: 'location-2', - created_at: 1505828744434, + created_at: '2020-06-29T10:23:51.766+00:00', + digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7736dfd5c', }, ], headers, diff --git a/spec/frontend/registry/explorer/pages/details_spec.js b/spec/frontend/registry/explorer/pages/details_spec.js index b7e01cad9bc..9bc0bae5c23 100644 --- a/spec/frontend/registry/explorer/pages/details_spec.js +++ b/spec/frontend/registry/explorer/pages/details_spec.js @@ -5,6 +5,7 @@ import component from '~/registry/explorer/pages/details.vue'; import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue'; import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue'; import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue'; +import TagsList from '~/registry/explorer/components/details_page/tags_list.vue'; import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue'; import { createStore } from '~/registry/explorer/stores/'; import { @@ -15,7 +16,7 @@ import { } from '~/registry/explorer/stores/mutation_types/'; import { tagsListResponse } from '../mock_data'; -import { TagsTable, DeleteModal } from '../stubs'; +import { DeleteModal } from '../stubs'; describe('Details Page', () => { let wrapper; @@ -25,18 +26,23 @@ describe('Details Page', () => { const findDeleteModal = () => wrapper.find(DeleteModal); const findPagination = () => wrapper.find(GlPagination); const findTagsLoader = () => wrapper.find(TagsLoader); - const findTagsTable = () => wrapper.find(TagsTable); + const findTagsList = () => wrapper.find(TagsList); const findDeleteAlert = () => wrapper.find(DeleteAlert); const findDetailsHeader = () => wrapper.find(DetailsHeader); const findEmptyTagsState = () => wrapper.find(EmptyTagsState); const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' })); + const tagsArrayToSelectedTags = tags => + tags.reduce((acc, c) => { + acc[c.name] = true; + return acc; + }, {}); + const mountComponent = options => { wrapper = shallowMount(component, { store, stubs: { - TagsTable, DeleteModal, }, mocks: { @@ -66,15 +72,18 @@ describe('Details Page', () => { describe('when isLoading is true', () => { beforeEach(() => { - mountComponent(); store.commit(SET_MAIN_LOADING, true); - return wrapper.vm.$nextTick(); + mountComponent(); }); afterEach(() => store.commit(SET_MAIN_LOADING, false)); - it('binds isLoading to tags-table', () => { - expect(findTagsTable().props('isLoading')).toBe(true); + it('shows the loader', () => { + expect(findTagsLoader().exists()).toBe(true); + }); + + it('does not show the list', () => { + expect(findTagsList().exists()).toBe(false); }); it('does not show pagination', () => { @@ -82,8 +91,9 @@ describe('Details Page', () => { }); }); - describe('table slots', () => { + describe('when the list of tags is empty', () => { beforeEach(() => { + store.commit(SET_TAGS_LIST_SUCCESS, []); mountComponent(); }); @@ -91,32 +101,37 @@ describe('Details Page', () => { expect(findEmptyTagsState().exists()).toBe(true); }); - it('has a skeleton loader', () => { - expect(findTagsLoader().exists()).toBe(true); + it('does not show the loader', () => { + expect(findTagsLoader().exists()).toBe(false); + }); + + it('does not show the list', () => { + expect(findTagsList().exists()).toBe(false); }); }); - describe('table', () => { + describe('list', () => { beforeEach(() => { mountComponent(); }); it('exists', () => { - expect(findTagsTable().exists()).toBe(true); + expect(findTagsList().exists()).toBe(true); }); it('has the correct props bound', () => { - expect(findTagsTable().props()).toMatchObject({ + expect(findTagsList().props()).toMatchObject({ isDesktop: true, - isLoading: false, tags: store.state.tags, }); }); describe('deleteEvent', () => { describe('single item', () => { + let tagToBeDeleted; beforeEach(() => { - findTagsTable().vm.$emit('delete', [store.state.tags[0].name]); + [tagToBeDeleted] = store.state.tags; + findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true }); }); it('open the modal', () => { @@ -124,7 +139,7 @@ describe('Details Page', () => { }); it('maps the selection to itemToBeDeleted', () => { - expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]); + expect(wrapper.vm.itemsToBeDeleted).toEqual([tagToBeDeleted]); }); it('tracks a single delete event', () => { @@ -136,7 +151,7 @@ describe('Details Page', () => { describe('multiple items', () => { beforeEach(() => { - findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name)); + findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags)); }); it('open the modal', () => { @@ -202,7 +217,7 @@ describe('Details Page', () => { describe('when one item is selected to be deleted', () => { beforeEach(() => { mountComponent(); - findTagsTable().vm.$emit('delete', [store.state.tags[0].name]); + findTagsList().vm.$emit('delete', { [store.state.tags[0].name]: true }); }); it('dispatch requestDeleteTag with the right parameters', () => { @@ -217,7 +232,7 @@ describe('Details Page', () => { describe('when more than one item is selected to be deleted', () => { beforeEach(() => { mountComponent(); - findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name)); + findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags)); }); it('dispatch requestDeleteTags with the right parameters', () => { diff --git a/spec/frontend/registry/explorer/stubs.js b/spec/frontend/registry/explorer/stubs.js index d3518c36c82..8f95fce2867 100644 --- a/spec/frontend/registry/explorer/stubs.js +++ b/spec/frontend/registry/explorer/stubs.js @@ -1,5 +1,5 @@ -import RealTagsTable from '~/registry/explorer/components/details_page/tags_table.vue'; import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; +import RealListItem from '~/registry/explorer/components/list_item.vue'; export const GlModal = { template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', @@ -18,11 +18,6 @@ export const RouterLink = { props: ['to'], }; -export const TagsTable = { - props: RealTagsTable.props, - template: `<div><slot name="empty"></slot><slot name="loader"></slot></div>`, -}; - export const DeleteModal = { template: '<div></div>', methods: { @@ -35,3 +30,13 @@ export const GlSkeletonLoader = { template: `<div><slot></slot></div>`, props: ['width', 'height'], }; + +export const ListItem = { + ...RealListItem, + data() { + return { + detailsSlots: [], + isDetailsShown: true, + }; + }, +}; diff --git a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap index 966acdf52be..11393c89d06 100644 --- a/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap +++ b/spec/frontend/registry/settings/components/__snapshots__/registry_settings_app_spec.js.snap @@ -2,24 +2,6 @@ exports[`Registry Settings App renders 1`] = ` <div> - <p> - - Tag expiration policy is designed to: - - </p> - - <ul> - <li> - Keep and protect the images that matter most. - </li> - - <li> - - Automatically remove extra images that aren't designed to be kept. - - </li> - </ul> - <settings-form-stub /> </div> `; diff --git a/spec/frontend/registry/settings/components/registry_settings_app_spec.js b/spec/frontend/registry/settings/components/registry_settings_app_spec.js index 95f784c9727..9551ee72e51 100644 --- a/spec/frontend/registry/settings/components/registry_settings_app_spec.js +++ b/spec/frontend/registry/settings/components/registry_settings_app_spec.js @@ -5,6 +5,11 @@ import SettingsForm from '~/registry/settings/components/settings_form.vue'; import { createStore } from '~/registry/settings/store/'; import { SET_SETTINGS, SET_INITIAL_STATE } from '~/registry/settings/store/mutation_types'; import { FETCH_SETTINGS_ERROR_MESSAGE } from '~/registry/shared/constants'; +import { + UNAVAILABLE_FEATURE_INTRO_TEXT, + UNAVAILABLE_USER_FEATURE_TEXT, +} from '~/registry/settings/constants'; + import { stringifiedFormOptions } from '../../shared/mock_data'; describe('Registry Settings App', () => { @@ -68,10 +73,8 @@ describe('Registry Settings App', () => { it('shows an alert', () => { const text = findAlert().text(); - expect(text).toContain( - 'The Container Registry tag expiration and retention policies for this project have not been enabled.', - ); - expect(text).toContain('Please contact your administrator.'); + expect(text).toContain(UNAVAILABLE_FEATURE_INTRO_TEXT); + expect(text).toContain(UNAVAILABLE_USER_FEATURE_TEXT); }); describe('an admin is visiting the page', () => { diff --git a/spec/frontend/registry/settings/components/settings_form_spec.js b/spec/frontend/registry/settings/components/settings_form_spec.js index 2b3e529b283..9b9ca92270c 100644 --- a/spec/frontend/registry/settings/components/settings_form_spec.js +++ b/spec/frontend/registry/settings/components/settings_form_spec.js @@ -7,6 +7,7 @@ import { UPDATE_SETTINGS_ERROR_MESSAGE, UPDATE_SETTINGS_SUCCESS_MESSAGE, } from '~/registry/shared/constants'; +import waitForPromises from 'helpers/wait_for_promises'; import { stringifiedFormOptions } from '../../shared/mock_data'; describe('Settings Form', () => { @@ -36,12 +37,17 @@ describe('Settings Form', () => { const findSaveButton = () => wrapper.find({ ref: 'save-button' }); const findLoadingIcon = (parent = wrapper) => parent.find(GlLoadingIcon); - const mountComponent = () => { + const mountComponent = (data = {}) => { wrapper = shallowMount(component, { stubs: { GlCard, GlLoadingIcon, }, + data() { + return { + ...data, + }; + }, mocks: { $toast: { show: jest.fn(), @@ -55,7 +61,6 @@ describe('Settings Form', () => { store = createStore(); store.dispatch('setInitialState', stringifiedFormOptions); dispatchSpy = jest.spyOn(store, 'dispatch'); - mountComponent(); jest.spyOn(Tracking, 'event'); }); @@ -63,20 +68,30 @@ describe('Settings Form', () => { wrapper.destroy(); }); + describe('data binding', () => { + it('v-model change update the settings property', () => { + mountComponent(); + findFields().vm.$emit('input', { newValue: 'foo' }); + expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' }); + }); + + it('v-model change update the api error property', () => { + const apiErrors = { baz: 'bar' }; + mountComponent({ apiErrors }); + expect(findFields().props('apiErrors')).toEqual(apiErrors); + findFields().vm.$emit('input', { newValue: 'foo', modified: 'baz' }); + expect(findFields().props('apiErrors')).toEqual({}); + }); + }); + describe('form', () => { let form; beforeEach(() => { + mountComponent(); form = findForm(); dispatchSpy.mockReturnValue(); }); - describe('data binding', () => { - it('v-model change update the settings property', () => { - findFields().vm.$emit('input', 'foo'); - expect(dispatchSpy).toHaveBeenCalledWith('updateSettings', { settings: 'foo' }); - }); - }); - describe('form reset event', () => { beforeEach(() => { form.trigger('reset'); @@ -108,24 +123,40 @@ describe('Settings Form', () => { expect(Tracking.event).toHaveBeenCalledWith(undefined, 'submit_form', trackingPayload); }); - it('show a success toast when submit succeed', () => { + it('show a success toast when submit succeed', async () => { dispatchSpy.mockResolvedValue(); form.trigger('submit'); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { - type: 'success', - }); + await waitForPromises(); + expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_SUCCESS_MESSAGE, { + type: 'success', }); }); - it('show an error toast when submit fails', () => { - dispatchSpy.mockRejectedValue(); - form.trigger('submit'); - return wrapper.vm.$nextTick().then(() => { + describe('when submit fails', () => { + it('shows an error', async () => { + dispatchSpy.mockRejectedValue({ response: {} }); + form.trigger('submit'); + await waitForPromises(); expect(wrapper.vm.$toast.show).toHaveBeenCalledWith(UPDATE_SETTINGS_ERROR_MESSAGE, { type: 'error', }); }); + + it('parses the error messages', async () => { + dispatchSpy.mockRejectedValue({ + response: { + data: { + message: { + foo: 'bar', + 'container_expiration_policy.name': ['baz'], + }, + }, + }, + }); + form.trigger('submit'); + await waitForPromises(); + expect(findFields().props('apiErrors')).toEqual({ name: 'baz' }); + }); }); }); }); @@ -134,6 +165,7 @@ describe('Settings Form', () => { describe('cancel button', () => { beforeEach(() => { store.commit('SET_SETTINGS', { foo: 'bar' }); + mountComponent(); }); it('has type reset', () => { @@ -165,6 +197,7 @@ describe('Settings Form', () => { describe('when isLoading is true', () => { beforeEach(() => { store.commit('TOGGLE_LOADING'); + mountComponent(); }); afterEach(() => { store.commit('TOGGLE_LOADING'); diff --git a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap index a9034b81d2f..69953fb5e03 100644 --- a/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap +++ b/spec/frontend/registry/shared/components/__snapshots__/expiration_policy_fields_spec.js.snap @@ -2,32 +2,30 @@ exports[`Expiration Policy Form renders 1`] = ` <div - class="lh-2" + class="gl-line-height-20" > <gl-form-group-stub id="expiration-policy-toggle-group" - label="Expiration policy:" + label="Cleanup policy:" label-align="right" label-cols="3" label-for="expiration-policy-toggle" > <div - class="d-flex align-items-start" + class="gl-display-flex" > <gl-toggle-stub id="expiration-policy-toggle" - labeloff="Toggle Status: OFF" - labelon="Toggle Status: ON" - labelposition="hidden" + labelposition="top" /> <span - class="mb-2 ml-1 lh-2" + class="gl-mb-3 gl-ml-3 gl-line-height-20" > - Docker tag expiration policy is <strong> - disabled + Disabled </strong> + - Tags matching the patterns defined below will be scheduled for deletion </span> </div> </gl-form-group-stub> @@ -116,7 +114,6 @@ exports[`Expiration Policy Form renders 1`] = ` <gl-form-group-stub id="expiration-policy-name-matching-group" - invalid-feedback="The value of this input should be less than 255 characters" label-align="right" label-cols="3" label-for="expiration-policy-name-matching" @@ -125,6 +122,7 @@ exports[`Expiration Policy Form renders 1`] = ` <gl-form-textarea-stub disabled="true" id="expiration-policy-name-matching" + noresize="true" placeholder=".*" trim="" value="" @@ -132,7 +130,6 @@ exports[`Expiration Policy Form renders 1`] = ` </gl-form-group-stub> <gl-form-group-stub id="expiration-policy-keep-name-group" - invalid-feedback="The value of this input should be less than 255 characters" label-align="right" label-cols="3" label-for="expiration-policy-keep-name" @@ -141,6 +138,7 @@ exports[`Expiration Policy Form renders 1`] = ` <gl-form-textarea-stub disabled="true" id="expiration-policy-keep-name" + noresize="true" placeholder="" trim="" value="" diff --git a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js index 4825351a6d3..ee765ffd1c0 100644 --- a/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js +++ b/spec/frontend/registry/shared/components/expiration_policy_fields_spec.js @@ -2,7 +2,7 @@ import { shallowMount } from '@vue/test-utils'; import { GlSprintf } from '@gitlab/ui'; import component from '~/registry/shared/components/expiration_policy_fields.vue'; -import { NAME_REGEX_LENGTH } from '~/registry/shared/constants'; +import { NAME_REGEX_LENGTH, ENABLED_TEXT, DISABLED_TEXT } from '~/registry/shared/constants'; import { formOptions } from '../mock_data'; describe('Expiration Policy Form', () => { @@ -94,7 +94,9 @@ describe('Expiration Policy Form', () => { : 'input'; element.vm.$emit(modelUpdateEvent, value); return wrapper.vm.$nextTick().then(() => { - expect(wrapper.emitted('input')).toEqual([[{ [modelName]: value }]]); + expect(wrapper.emitted('input')).toEqual([ + [{ newValue: { [modelName]: value }, modified: modelName }], + ]); }); }); @@ -126,42 +128,61 @@ describe('Expiration Policy Form', () => { }); describe.each` - modelName | elementName | stateVariable - ${'name_regex'} | ${'name-matching'} | ${'nameRegexState'} - ${'name_regex_keep'} | ${'keep-name'} | ${'nameKeepRegexState'} - `('regex textarea validation', ({ modelName, elementName, stateVariable }) => { - describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { - const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); - - beforeEach(() => { - mountComponent({ value: { [modelName]: invalidString } }); + modelName | elementName + ${'name_regex'} | ${'name-matching'} + ${'name_regex_keep'} | ${'keep-name'} + `('regex textarea validation', ({ modelName, elementName }) => { + const invalidString = new Array(NAME_REGEX_LENGTH + 2).join(','); + + describe('when apiError contains an error message', () => { + const errorMessage = 'something went wrong'; + + it('shows the error message on the relevant field', () => { + mountComponent({ apiErrors: { [modelName]: errorMessage } }); + expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage); }); - it(`${stateVariable} is false`, () => { - expect(wrapper.vm.textAreaState[stateVariable]).toBe(false); - }); - - it('emit the @invalidated event', () => { - expect(wrapper.emitted('invalidated')).toBeTruthy(); + it('gives precedence to API errors compared to local ones', () => { + mountComponent({ + apiErrors: { [modelName]: errorMessage }, + value: { [modelName]: invalidString }, + }); + expect(findFormGroup(elementName).attributes('invalid-feedback')).toBe(errorMessage); }); }); - it('if the user did not type validation is null', () => { - mountComponent({ value: { [modelName]: '' } }); - return wrapper.vm.$nextTick().then(() => { - expect(wrapper.vm.textAreaState[stateVariable]).toBe(null); + describe('when apiErrors is empty', () => { + it('if the user did not type validation is null', async () => { + mountComponent({ value: { [modelName]: '' } }); + expect(findFormGroup(elementName).attributes('state')).toBeUndefined(); expect(wrapper.emitted('validated')).toBeTruthy(); }); - }); - it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { - mountComponent({ value: { [modelName]: 'foo' } }); - return wrapper.vm.$nextTick().then(() => { + it(`if the user typed and is less than ${NAME_REGEX_LENGTH} state is true`, () => { + mountComponent({ value: { [modelName]: 'foo' } }); + const formGroup = findFormGroup(elementName); const formElement = findFormElements(elementName, formGroup); expect(formGroup.attributes('state')).toBeTruthy(); expect(formElement.attributes('state')).toBeTruthy(); }); + + describe(`when name regex is longer than ${NAME_REGEX_LENGTH}`, () => { + beforeEach(() => { + mountComponent({ value: { [modelName]: invalidString } }); + }); + + it('textAreaValidation state is false', () => { + expect(findFormGroup(elementName).attributes('state')).toBeUndefined(); + // we are forced to check the model attribute because falsy attrs are all casted to undefined in attrs + // while in this case false shows an error and null instead shows nothing. + expect(wrapper.vm.textAreaValidation[modelName].state).toBe(false); + }); + + it('emit the @invalidated event', () => { + expect(wrapper.emitted('invalidated')).toBeTruthy(); + }); + }); }); }); @@ -169,13 +190,13 @@ describe('Expiration Policy Form', () => { it('toggleDescriptionText show disabled when settings.enabled is false', () => { mountComponent(); const toggleHelpText = findFormGroup('toggle').find('span'); - expect(toggleHelpText.html()).toContain('disabled'); + expect(toggleHelpText.html()).toContain(DISABLED_TEXT); }); it('toggleDescriptionText show enabled when settings.enabled is true', () => { mountComponent({ value: { enabled: true } }); const toggleHelpText = findFormGroup('toggle').find('span'); - expect(toggleHelpText.html()).toContain('enabled'); + expect(toggleHelpText.html()).toContain(ENABLED_TEXT); }); }); }); |