diff options
Diffstat (limited to 'spec/frontend/registry/explorer')
13 files changed, 849 insertions, 402 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, + }; + }, +}; |