summaryrefslogtreecommitdiff
path: root/spec/frontend/packages_and_registries/container_registry/explorer
diff options
context:
space:
mode:
Diffstat (limited to 'spec/frontend/packages_and_registries/container_registry/explorer')
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap84
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js73
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js152
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap61
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js116
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js152
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js304
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js54
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js74
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js57
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js382
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js314
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js45
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap15
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap83
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js87
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js94
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js37
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js223
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js88
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js45
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js135
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js78
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js269
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js521
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js24
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js597
-rw-r--r--spec/frontend/packages_and_registries/container_registry/explorer/stubs.js45
28 files changed, 4209 insertions, 0 deletions
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap
new file mode 100644
index 00000000000..7044c1285d8
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/__snapshots__/registry_breadcrumb_spec.js.snap
@@ -0,0 +1,84 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
+<div
+ class="gl-breadcrumbs"
+>
+
+ <ol
+ class="breadcrumb gl-breadcrumb-list"
+ >
+ <li
+ class="breadcrumb-item gl-breadcrumb-item"
+ >
+ <a
+ class=""
+ href="/"
+ target="_self"
+ >
+ <span>
+
+ </span>
+
+ <span
+ class="gl-breadcrumb-separator"
+ data-testid="separator"
+ >
+ <svg
+ aria-hidden="true"
+ class="gl-icon s8"
+ data-testid="angle-right-icon"
+ role="img"
+ >
+ <use
+ href="#angle-right"
+ />
+ </svg>
+ </span>
+ </a>
+ </li>
+ <li
+ class="breadcrumb-item gl-breadcrumb-item"
+ >
+ <a
+ class=""
+ href="#"
+ target="_self"
+ >
+ <span>
+
+ </span>
+
+ <!---->
+ </a>
+ </li>
+ </ol>
+</div>
+`;
+
+exports[`Registry Breadcrumb when is rootRoute renders 1`] = `
+<div
+ class="gl-breadcrumbs"
+>
+
+ <ol
+ class="breadcrumb gl-breadcrumb-list"
+ >
+ <li
+ class="breadcrumb-item gl-breadcrumb-item"
+ >
+ <a
+ class=""
+ href="/"
+ target="_self"
+ >
+ <span>
+
+ </span>
+
+ <!---->
+ </a>
+ </li>
+ </ol>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
new file mode 100644
index 00000000000..6d7bf528495
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_button_spec.js
@@ -0,0 +1,73 @@
+import { GlButton } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import component from '~/packages_and_registries/container_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',
+ icon: 'remove',
+ title: 'Foo title',
+ variant: 'danger',
+ disabled: 'true',
+ category: 'secondary',
+ });
+ });
+
+ 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/packages_and_registries/container_registry/explorer/components/delete_image_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js
new file mode 100644
index 00000000000..620c96e8c9e
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/delete_image_spec.js
@@ -0,0 +1,152 @@
+import { shallowMount } from '@vue/test-utils';
+import waitForPromises from 'helpers/wait_for_promises';
+import component from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue';
+import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index';
+import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql';
+import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
+
+describe('Delete Image', () => {
+ let wrapper;
+ const id = '1';
+ const storeMock = {
+ readQuery: jest.fn().mockReturnValue({
+ containerRepository: {
+ status: 'foo',
+ },
+ }),
+ writeQuery: jest.fn(),
+ };
+
+ const updatePayload = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ status: 'baz',
+ },
+ },
+ },
+ };
+
+ const findButton = () => wrapper.find('button');
+
+ const mountComponent = ({
+ propsData = { id },
+ mutate = jest.fn().mockResolvedValue({}),
+ } = {}) => {
+ wrapper = shallowMount(component, {
+ propsData,
+ mocks: {
+ $apollo: {
+ mutate,
+ },
+ },
+ scopedSlots: {
+ default: '<button @click="props.doDelete">test</button>',
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('executes apollo mutate on doDelete', () => {
+ const mutate = jest.fn().mockResolvedValue({});
+ mountComponent({ mutate });
+
+ wrapper.vm.doDelete();
+
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: deleteContainerRepositoryMutation,
+ variables: {
+ id,
+ },
+ update: undefined,
+ });
+ });
+
+ it('on success emits the correct events', async () => {
+ const mutate = jest.fn().mockResolvedValue({});
+ mountComponent({ mutate });
+
+ wrapper.vm.doDelete();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('start')).toEqual([[]]);
+ expect(wrapper.emitted('success')).toEqual([[]]);
+ expect(wrapper.emitted('end')).toEqual([[]]);
+ });
+
+ it('when a payload contains an error emits an error event', async () => {
+ const mutate = jest
+ .fn()
+ .mockResolvedValue({ data: { destroyContainerRepository: { errors: ['foo'] } } });
+
+ mountComponent({ mutate });
+ wrapper.vm.doDelete();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[['foo']]]);
+ });
+
+ it('when the api call errors emits an error event', async () => {
+ const mutate = jest.fn().mockRejectedValue('error');
+
+ mountComponent({ mutate });
+ wrapper.vm.doDelete();
+
+ await waitForPromises();
+
+ expect(wrapper.emitted('error')).toEqual([[['error']]]);
+ });
+
+ it('uses the update function, when the prop is set to true', () => {
+ const mutate = jest.fn().mockResolvedValue({});
+
+ mountComponent({ mutate, propsData: { id, useUpdateFn: true } });
+ wrapper.vm.doDelete();
+
+ expect(mutate).toHaveBeenCalledWith({
+ mutation: deleteContainerRepositoryMutation,
+ variables: {
+ id,
+ },
+ update: wrapper.vm.updateImageStatus,
+ });
+ });
+
+ it('updateImage status reads and write to the cache', () => {
+ mountComponent();
+
+ const variables = {
+ id,
+ first: GRAPHQL_PAGE_SIZE,
+ };
+
+ wrapper.vm.updateImageStatus(storeMock, updatePayload);
+
+ expect(storeMock.readQuery).toHaveBeenCalledWith({
+ query: getContainerRepositoryDetailsQuery,
+ variables,
+ });
+ expect(storeMock.writeQuery).toHaveBeenCalledWith({
+ query: getContainerRepositoryDetailsQuery,
+ variables,
+ data: {
+ containerRepository: {
+ status: updatePayload.data.destroyContainerRepository.containerRepository.status,
+ },
+ },
+ });
+ });
+
+ it('binds the doDelete function to the default scoped slot', () => {
+ const mutate = jest.fn().mockResolvedValue({});
+ mountComponent({ mutate });
+ findButton().trigger('click');
+ expect(mutate).toHaveBeenCalled();
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap
new file mode 100644
index 00000000000..5f191ef5561
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/__snapshots__/tags_loader_spec.js.snap
@@ -0,0 +1,61 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`TagsLoader component has the correct markup 1`] = `
+<div>
+ <div>
+ <rect
+ height="15"
+ rx="4"
+ width="15"
+ x="0"
+ y="12.5"
+ />
+
+ <rect
+ height="20"
+ rx="4"
+ width="250"
+ x="25"
+ y="10"
+ />
+
+ <circle
+ cx="290"
+ cy="20"
+ r="10"
+ />
+
+ <rect
+ height="20"
+ rx="4"
+ width="100"
+ x="315"
+ y="10"
+ />
+
+ <rect
+ height="20"
+ rx="4"
+ width="100"
+ x="500"
+ y="10"
+ />
+
+ <rect
+ height="20"
+ rx="4"
+ width="100"
+ x="630"
+ y="10"
+ />
+
+ <rect
+ height="40"
+ rx="4"
+ width="40"
+ x="960"
+ y="0"
+ />
+ </div>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
new file mode 100644
index 00000000000..e25162f4da5
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_alert_spec.js
@@ -0,0 +1,116 @@
+import { GlAlert, GlSprintf, GlLink } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue';
+import {
+ DELETE_TAG_SUCCESS_MESSAGE,
+ DELETE_TAG_ERROR_MESSAGE,
+ DELETE_TAGS_SUCCESS_MESSAGE,
+ DELETE_TAGS_ERROR_MESSAGE,
+ ADMIN_GARBAGE_COLLECTION_TIP,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+
+describe('Delete alert', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.find(GlAlert);
+ const findLink = () => wrapper.find(GlLink);
+
+ const mountComponent = (propsData) => {
+ wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when deleteAlertType is null', () => {
+ it('does not show the alert', () => {
+ mountComponent();
+ expect(findAlert().exists()).toBe(false);
+ });
+ });
+
+ describe('when deleteAlertType is not null', () => {
+ describe('success states', () => {
+ describe.each`
+ deleteAlertType | message
+ ${'success_tag'} | ${DELETE_TAG_SUCCESS_MESSAGE}
+ ${'success_tags'} | ${DELETE_TAGS_SUCCESS_MESSAGE}
+ `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => {
+ it('alert exists', () => {
+ mountComponent({ deleteAlertType });
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ describe('when the user is an admin', () => {
+ beforeEach(() => {
+ mountComponent({
+ deleteAlertType,
+ isAdmin: true,
+ garbageCollectionHelpPagePath: 'foo',
+ });
+ });
+
+ it(`alert title is ${message}`, () => {
+ expect(findAlert().attributes('title')).toBe(message);
+ });
+
+ it('alert body contains admin tip', () => {
+ expect(findAlert().text()).toMatchInterpolatedText(ADMIN_GARBAGE_COLLECTION_TIP);
+ });
+
+ it('alert body contains link', () => {
+ const alertLink = findLink();
+ expect(alertLink.exists()).toBe(true);
+ expect(alertLink.attributes('href')).toBe('foo');
+ });
+ });
+
+ describe('when the user is not an admin', () => {
+ it('alert exist and text is appropriate', () => {
+ mountComponent({ deleteAlertType });
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(message);
+ });
+ });
+ });
+ });
+ describe('error states', () => {
+ describe.each`
+ deleteAlertType | message
+ ${'danger_tag'} | ${DELETE_TAG_ERROR_MESSAGE}
+ ${'danger_tags'} | ${DELETE_TAGS_ERROR_MESSAGE}
+ `('when deleteAlertType is $deleteAlertType', ({ deleteAlertType, message }) => {
+ it('alert exists', () => {
+ mountComponent({ deleteAlertType });
+ expect(findAlert().exists()).toBe(true);
+ });
+
+ describe('when the user is an admin', () => {
+ it('alert exist and text is appropriate', () => {
+ mountComponent({ deleteAlertType });
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(message);
+ });
+ });
+
+ describe('when the user is not an admin', () => {
+ it('alert exist and text is appropriate', () => {
+ mountComponent({ deleteAlertType });
+ expect(findAlert().exists()).toBe(true);
+ expect(findAlert().text()).toBe(message);
+ });
+ });
+ });
+ });
+
+ describe('dismissing alert', () => {
+ it('GlAlert dismiss event triggers a change event', () => {
+ mountComponent({ deleteAlertType: 'success_tags' });
+ findAlert().vm.$emit('dismiss');
+ expect(wrapper.emitted('change')).toEqual([[null]]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js
new file mode 100644
index 00000000000..16c9485e69e
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/delete_modal_spec.js
@@ -0,0 +1,152 @@
+import { GlSprintf, GlFormInput } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue';
+import {
+ REMOVE_TAG_CONFIRMATION_TEXT,
+ REMOVE_TAGS_CONFIRMATION_TEXT,
+ DELETE_IMAGE_CONFIRMATION_TITLE,
+ DELETE_IMAGE_CONFIRMATION_TEXT,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import { GlModal } from '../../stubs';
+
+describe('Delete Modal', () => {
+ let wrapper;
+
+ const findModal = () => wrapper.findComponent(GlModal);
+ const findDescription = () => wrapper.find('[data-testid="description"]');
+ const findInputComponent = () => wrapper.findComponent(GlFormInput);
+
+ const mountComponent = (propsData) => {
+ wrapper = shallowMount(component, {
+ propsData,
+ stubs: {
+ GlSprintf,
+ GlModal,
+ },
+ });
+ };
+
+ const expectPrimaryActionStatus = (disabled = true) =>
+ expect(findModal().props('actionPrimary')).toMatchObject(
+ expect.objectContaining({
+ attributes: [{ variant: 'danger' }, { disabled }],
+ }),
+ );
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('contains a GlModal', () => {
+ mountComponent();
+ expect(findModal().exists()).toBe(true);
+ });
+
+ describe('events', () => {
+ it.each`
+ glEvent | localEvent
+ ${'primary'} | ${'confirmDelete'}
+ ${'cancel'} | ${'cancelDelete'}
+ `('GlModal $glEvent emits $localEvent', ({ glEvent, localEvent }) => {
+ mountComponent();
+ findModal().vm.$emit(glEvent);
+ expect(wrapper.emitted(localEvent)).toEqual([[]]);
+ });
+ });
+
+ describe('methods', () => {
+ it('show calls gl-modal show', () => {
+ mountComponent();
+ wrapper.vm.show();
+ expect(GlModal.methods.show).toHaveBeenCalled();
+ });
+ });
+
+ describe('when we are deleting images', () => {
+ it('has the correct title', () => {
+ mountComponent({ deleteImage: true });
+
+ expect(wrapper.text()).toContain(DELETE_IMAGE_CONFIRMATION_TITLE);
+ });
+
+ it('has the correct description', () => {
+ mountComponent({ deleteImage: true });
+
+ expect(wrapper.text()).toContain(
+ DELETE_IMAGE_CONFIRMATION_TEXT.replace('%{code}', '').trim(),
+ );
+ });
+
+ describe('delete button', () => {
+ const itemsToBeDeleted = [{ project: { path: 'foo' } }];
+
+ it('is disabled by default', () => {
+ mountComponent({ deleteImage: true });
+
+ expectPrimaryActionStatus();
+ });
+
+ it('if the user types something different from the project path is disabled', async () => {
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'bar');
+
+ await nextTick();
+
+ expectPrimaryActionStatus();
+ });
+
+ it('if the user types the project path it is enabled', async () => {
+ mountComponent({ deleteImage: true, itemsToBeDeleted });
+
+ findInputComponent().vm.$emit('input', 'foo');
+
+ await nextTick();
+
+ expectPrimaryActionStatus(false);
+ });
+ });
+ });
+
+ describe('when we are deleting tags', () => {
+ it('delete button is enabled', () => {
+ mountComponent();
+
+ expectPrimaryActionStatus(false);
+ });
+
+ describe('itemsToBeDeleted contains one element', () => {
+ beforeEach(() => {
+ mountComponent({ itemsToBeDeleted: [{ path: 'foo' }] });
+ });
+
+ it(`has the correct description`, () => {
+ expect(findDescription().text()).toBe(
+ REMOVE_TAG_CONFIRMATION_TEXT.replace('%{item}', 'foo'),
+ );
+ });
+
+ it('has the correct title', () => {
+ expect(wrapper.text()).toContain('Remove tag');
+ });
+ });
+
+ describe('itemsToBeDeleted contains more than element', () => {
+ beforeEach(() => {
+ mountComponent({ itemsToBeDeleted: [{ path: 'foo' }, { path: 'bar' }] });
+ });
+
+ it(`has the correct description`, () => {
+ expect(findDescription().text()).toBe(
+ REMOVE_TAGS_CONFIRMATION_TEXT.replace('%{item}', '2'),
+ );
+ });
+
+ it('has the correct title', () => {
+ expect(wrapper.text()).toContain('Remove tags');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
new file mode 100644
index 00000000000..f06300efa29
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/details_header_spec.js
@@ -0,0 +1,304 @@
+import { GlDropdownItem, GlIcon } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import { GlDropdown } from 'jest/packages_and_registries/container_registry/explorer/stubs';
+import { useFakeDate } from 'helpers/fake_date';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import waitForPromises from 'helpers/wait_for_promises';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue';
+import {
+ UNSCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ONGOING_STATUS,
+ UNFINISHED_STATUS,
+ CLEANUP_DISABLED_TEXT,
+ CLEANUP_DISABLED_TOOLTIP,
+ CLEANUP_SCHEDULED_TOOLTIP,
+ CLEANUP_ONGOING_TOOLTIP,
+ CLEANUP_UNFINISHED_TOOLTIP,
+ ROOT_IMAGE_TEXT,
+ ROOT_IMAGE_TOOLTIP,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import getContainerRepositoryTagCountQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags_count.query.graphql';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+import { imageTagsCountMock } from '../../mock_data';
+
+describe('Details Header', () => {
+ let wrapper;
+ let apolloProvider;
+ let localVue;
+
+ const defaultImage = {
+ name: 'foo',
+ updatedAt: '2020-11-03T13:29:21Z',
+ canDelete: true,
+ project: {
+ visibility: 'public',
+ containerExpirationPolicy: {
+ enabled: false,
+ },
+ },
+ };
+
+ // set the date to Dec 4, 2020
+ useFakeDate(2020, 11, 4);
+ const findByTestId = (testId) => wrapper.find(`[data-testid="${testId}"]`);
+
+ const findLastUpdatedAndVisibility = () => findByTestId('updated-and-visibility');
+ const findTitle = () => findByTestId('title');
+ const findTagsCount = () => findByTestId('tags-count');
+ const findCleanup = () => findByTestId('cleanup');
+ const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
+ const findInfoIcon = () => wrapper.findComponent(GlIcon);
+
+ const waitForMetadataItems = async () => {
+ // Metadata items are printed by a loop in the title-area and it takes two ticks for them to be available
+ await wrapper.vm.$nextTick();
+ await wrapper.vm.$nextTick();
+ };
+
+ const mountComponent = ({
+ propsData = { image: defaultImage },
+ resolver = jest.fn().mockResolvedValue(imageTagsCountMock()),
+ $apollo = undefined,
+ } = {}) => {
+ const mocks = {};
+
+ if ($apollo) {
+ mocks.$apollo = $apollo;
+ } else {
+ localVue = createLocalVue();
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[getContainerRepositoryTagCountQuery, resolver]];
+ apolloProvider = createMockApollo(requestHandlers);
+ }
+
+ wrapper = shallowMount(component, {
+ localVue,
+ apolloProvider,
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ mocks,
+ stubs: {
+ TitleArea,
+ GlDropdown,
+ GlDropdownItem,
+ },
+ });
+ };
+
+ afterEach(() => {
+ // if we want to mix createMockApollo and manual mocks we need to reset everything
+ wrapper.destroy();
+ apolloProvider = undefined;
+ localVue = undefined;
+ wrapper = null;
+ });
+
+ describe('image name', () => {
+ describe('missing image name', () => {
+ beforeEach(() => {
+ mountComponent({ propsData: { image: { ...defaultImage, name: '' } } });
+
+ return waitForPromises();
+ });
+
+ it('root image ', () => {
+ expect(findTitle().text()).toBe(ROOT_IMAGE_TEXT);
+ });
+
+ it('has an icon', () => {
+ expect(findInfoIcon().exists()).toBe(true);
+ expect(findInfoIcon().props('name')).toBe('information-o');
+ });
+
+ it('has a tooltip', () => {
+ const tooltip = getBinding(findInfoIcon().element, 'gl-tooltip');
+ expect(tooltip.value).toBe(ROOT_IMAGE_TOOLTIP);
+ });
+ });
+
+ describe('with image name present', () => {
+ beforeEach(() => {
+ mountComponent();
+
+ return waitForPromises();
+ });
+
+ it('shows image.name ', () => {
+ expect(findTitle().text()).toContain('foo');
+ });
+
+ it('has no icon', () => {
+ expect(findInfoIcon().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('delete button', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findDeleteButton().exists()).toBe(true);
+ });
+
+ it('has the correct text', () => {
+ mountComponent();
+
+ expect(findDeleteButton().text()).toBe('Delete image repository');
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+
+ expect(findDeleteButton().attributes()).toMatchObject(
+ expect.objectContaining({
+ variant: 'danger',
+ }),
+ );
+ });
+
+ it('emits the correct event', () => {
+ mountComponent();
+
+ findDeleteButton().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+
+ it.each`
+ canDelete | disabled | isDisabled
+ ${true} | ${false} | ${undefined}
+ ${true} | ${true} | ${'true'}
+ ${false} | ${false} | ${'true'}
+ ${false} | ${true} | ${'true'}
+ `(
+ 'when canDelete is $canDelete and disabled is $disabled is $isDisabled that the button is disabled',
+ ({ canDelete, disabled, isDisabled }) => {
+ mountComponent({ propsData: { image: { ...defaultImage, canDelete }, disabled } });
+
+ expect(findDeleteButton().attributes('disabled')).toBe(isDisabled);
+ },
+ );
+ });
+
+ describe('metadata items', () => {
+ describe('tags count', () => {
+ it('displays "-- tags" while loading', async () => {
+ // here we are forced to mock apollo because `waitForMetadataItems` waits
+ // for two ticks, de facto allowing the promise to resolve, so there is
+ // no way to catch the component as both rendered and in loading state
+ mountComponent({ $apollo: { queries: { containerRepository: { loading: true } } } });
+
+ await waitForMetadataItems();
+
+ expect(findTagsCount().props('text')).toBe('-- tags');
+ });
+
+ it('when there is more than one tag has the correct text', async () => {
+ mountComponent();
+
+ await waitForPromises();
+ await waitForMetadataItems();
+
+ expect(findTagsCount().props('text')).toBe('13 tags');
+ });
+
+ it('when there is one tag has the correct text', async () => {
+ mountComponent({
+ resolver: jest.fn().mockResolvedValue(imageTagsCountMock({ tagsCount: 1 })),
+ });
+
+ await waitForPromises();
+ await waitForMetadataItems();
+
+ expect(findTagsCount().props('text')).toBe('1 tag');
+ });
+
+ it('has the correct icon', async () => {
+ mountComponent();
+ await waitForMetadataItems();
+
+ expect(findTagsCount().props('icon')).toBe('tag');
+ });
+ });
+
+ describe('cleanup metadata item', () => {
+ it('has the correct icon', async () => {
+ mountComponent();
+ await waitForMetadataItems();
+
+ expect(findCleanup().props('icon')).toBe('expire');
+ });
+
+ it('when the expiration policy is disabled', async () => {
+ mountComponent();
+ await waitForMetadataItems();
+
+ expect(findCleanup().props()).toMatchObject({
+ text: CLEANUP_DISABLED_TEXT,
+ textTooltip: CLEANUP_DISABLED_TOOLTIP,
+ });
+ });
+
+ it.each`
+ status | text | tooltip
+ ${UNSCHEDULED_STATUS} | ${'Cleanup will run in 1 month'} | ${''}
+ ${SCHEDULED_STATUS} | ${'Cleanup pending'} | ${CLEANUP_SCHEDULED_TOOLTIP}
+ ${ONGOING_STATUS} | ${'Cleanup in progress'} | ${CLEANUP_ONGOING_TOOLTIP}
+ ${UNFINISHED_STATUS} | ${'Cleanup incomplete'} | ${CLEANUP_UNFINISHED_TOOLTIP}
+ `(
+ 'when the status is $status the text is $text and the tooltip is $tooltip',
+ async ({ status, text, tooltip }) => {
+ mountComponent({
+ propsData: {
+ image: {
+ ...defaultImage,
+ expirationPolicyCleanupStatus: status,
+ project: {
+ containerExpirationPolicy: { enabled: true, nextRunAt: '2021-01-03T14:29:21Z' },
+ },
+ },
+ },
+ });
+ await waitForMetadataItems();
+
+ expect(findCleanup().props()).toMatchObject({
+ text,
+ textTooltip: tooltip,
+ });
+ },
+ );
+ });
+
+ describe('visibility and updated at ', () => {
+ it('has last updated text', async () => {
+ mountComponent();
+ await waitForMetadataItems();
+
+ expect(findLastUpdatedAndVisibility().props('text')).toBe('Last updated 1 month ago');
+ });
+
+ describe('visibility icon', () => {
+ it('shows an eye when the project is public', async () => {
+ mountComponent();
+ await waitForMetadataItems();
+
+ expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye');
+ });
+ it('shows an eye slashed when the project is not public', async () => {
+ mountComponent({
+ propsData: { image: { ...defaultImage, project: { visibility: 'private' } } },
+ });
+ await waitForMetadataItems();
+
+ expect(findLastUpdatedAndVisibility().props('icon')).toBe('eye-slash');
+ });
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js
new file mode 100644
index 00000000000..f14284e9efe
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/empty_state_spec.js
@@ -0,0 +1,54 @@
+import { GlEmptyState } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
+import {
+ NO_TAGS_TITLE,
+ NO_TAGS_MESSAGE,
+ MISSING_OR_DELETED_IMAGE_TITLE,
+ MISSING_OR_DELETED_IMAGE_MESSAGE,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+
+describe('EmptyTagsState component', () => {
+ let wrapper;
+
+ const findEmptyState = () => wrapper.find(GlEmptyState);
+
+ const mountComponent = (propsData) => {
+ wrapper = shallowMount(component, {
+ stubs: {
+ GlEmptyState,
+ },
+ propsData,
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('contains gl-empty-state', () => {
+ mountComponent();
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it.each`
+ isEmptyImage | title | description
+ ${false} | ${NO_TAGS_TITLE} | ${NO_TAGS_MESSAGE}
+ ${true} | ${MISSING_OR_DELETED_IMAGE_TITLE} | ${MISSING_OR_DELETED_IMAGE_MESSAGE}
+ `(
+ 'when isEmptyImage is $isEmptyImage has the correct props',
+ ({ isEmptyImage, title, description }) => {
+ mountComponent({
+ noContainersImage: 'foo',
+ isEmptyImage,
+ });
+
+ expect(findEmptyState().props()).toMatchObject({
+ title,
+ description,
+ svgPath: 'foo',
+ });
+ },
+ );
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js
new file mode 100644
index 00000000000..1a27481a828
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert_spec.js
@@ -0,0 +1,74 @@
+import { GlAlert, GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue';
+import {
+ DELETE_ALERT_TITLE,
+ DELETE_ALERT_LINK_TEXT,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+
+describe('Partial Cleanup alert', () => {
+ let wrapper;
+
+ const findAlert = () => wrapper.find(GlAlert);
+ const findRunLink = () => wrapper.find('[data-testid="run-link"');
+ const findHelpLink = () => wrapper.find('[data-testid="help-link"');
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ stubs: { GlSprintf },
+ propsData: {
+ runCleanupPoliciesHelpPagePath: 'foo',
+ cleanupPoliciesHelpPagePath: 'bar',
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it(`gl-alert has the correct properties`, () => {
+ mountComponent();
+
+ expect(findAlert().props()).toMatchObject({
+ title: DELETE_ALERT_TITLE,
+ variant: 'warning',
+ });
+ });
+
+ it('has the right text', () => {
+ mountComponent();
+
+ expect(wrapper.text()).toMatchInterpolatedText(DELETE_ALERT_LINK_TEXT);
+ });
+
+ it('contains run link', () => {
+ mountComponent();
+
+ const link = findRunLink();
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: 'foo',
+ target: '_blank',
+ });
+ });
+
+ it('contains help link', () => {
+ mountComponent();
+
+ const link = findHelpLink();
+ expect(link.exists()).toBe(true);
+ expect(link.attributes()).toMatchObject({
+ href: 'bar',
+ target: '_blank',
+ });
+ });
+
+ it('GlAlert dismiss event triggers a dismiss event', () => {
+ mountComponent();
+
+ findAlert().vm.$emit('dismiss');
+ expect(wrapper.emitted('dismiss')).toEqual([[]]);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js
new file mode 100644
index 00000000000..a11b102d9a6
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/status_alert_spec.js
@@ -0,0 +1,57 @@
+import { GlLink, GlSprintf, GlAlert } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue';
+import {
+ DELETE_SCHEDULED,
+ DELETE_FAILED,
+ PACKAGE_DELETE_HELP_PAGE_PATH,
+ SCHEDULED_FOR_DELETION_STATUS_TITLE,
+ SCHEDULED_FOR_DELETION_STATUS_MESSAGE,
+ FAILED_DELETION_STATUS_TITLE,
+ FAILED_DELETION_STATUS_MESSAGE,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+
+describe('Status Alert', () => {
+ let wrapper;
+
+ const findLink = () => wrapper.find(GlLink);
+ const findAlert = () => wrapper.find(GlAlert);
+ const findMessage = () => wrapper.find('[data-testid="message"]');
+
+ const mountComponent = (propsData) => {
+ wrapper = shallowMount(component, {
+ propsData,
+ stubs: {
+ GlSprintf,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it.each`
+ status | title | variant | message | link
+ ${DELETE_SCHEDULED} | ${SCHEDULED_FOR_DELETION_STATUS_TITLE} | ${'info'} | ${SCHEDULED_FOR_DELETION_STATUS_MESSAGE} | ${PACKAGE_DELETE_HELP_PAGE_PATH}
+ ${DELETE_FAILED} | ${FAILED_DELETION_STATUS_TITLE} | ${'warning'} | ${FAILED_DELETION_STATUS_MESSAGE} | ${''}
+ `(
+ `when the status is $status, title is $title, variant is $variant, message is $message and the link is $link`,
+ ({ status, title, variant, message, link }) => {
+ mountComponent({ status });
+
+ expect(findMessage().text()).toMatchInterpolatedText(message);
+ expect(findAlert().props()).toMatchObject({
+ title,
+ variant,
+ });
+ if (link) {
+ expect(findLink().attributes()).toMatchObject({
+ target: '_blank',
+ href: link,
+ });
+ }
+ },
+ );
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
new file mode 100644
index 00000000000..00b1d03b7c2
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row_spec.js
@@ -0,0 +1,382 @@
+import { GlFormCheckbox, GlSprintf, GlIcon, GlDropdown, GlDropdownItem } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { nextTick } from 'vue';
+
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
+import {
+ REMOVE_TAG_BUTTON_TITLE,
+ MISSING_MANIFEST_WARNING_TOOLTIP,
+ NOT_AVAILABLE_TEXT,
+ NOT_AVAILABLE_SIZE,
+} from '~/packages_and_registries/container_registry/explorer/constants/index';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import DetailsRow from '~/vue_shared/components/registry/details_row.vue';
+import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
+
+import { tagsMock } from '../../mock_data';
+import { ListItem } from '../../stubs';
+
+describe('tags list row', () => {
+ let wrapper;
+ const [tag] = [...tagsMock];
+
+ const defaultProps = { tag, isMobile: false, index: 0 };
+
+ const findCheckbox = () => wrapper.findComponent(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.findComponent(ClipboardButton);
+ const findTimeAgoTooltip = () => wrapper.findComponent(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.findComponent(GlIcon);
+ const findAdditionalActionsMenu = () => wrapper.findComponent(GlDropdown);
+ const findDeleteButton = () => wrapper.findComponent(GlDropdownItem);
+
+ const mountComponent = (propsData = defaultProps) => {
+ wrapper = shallowMount(component, {
+ stubs: {
+ GlSprintf,
+ ListItem,
+ DetailsRow,
+ GlDropdown,
+ },
+ 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, canDelete: false };
+
+ mountComponent({ ...defaultProps, tag: customTag });
+
+ expect(findCheckbox().exists()).toBe(false);
+ });
+
+ it.each`
+ digest | disabled
+ ${'foo'} | ${true}
+ ${null} | ${false}
+ ${null} | ${true}
+ ${'foo'} | ${true}
+ `('is disabled when the digest $digest and disabled is $disabled', ({ digest, disabled }) => {
+ mountComponent({ tag: { ...tag, digest }, disabled });
+
+ 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, isMobile: true });
+
+ 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: tag.location,
+ title: tag.location,
+ });
+ });
+
+ it('is disabled when the component is disabled', () => {
+ mountComponent({ ...defaultProps, disabled: true });
+
+ expect(findClipboardButton().attributes('disabled')).toBe('true');
+ });
+ });
+
+ 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 totalSize and layers', () => {
+ mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024', layers: 10 } });
+
+ expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers');
+ });
+
+ it('when totalSize is giantic', () => {
+ mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1099511627776', layers: 2 } });
+
+ expect(findSize().text()).toMatchInterpolatedText('1024.00 GiB · 2 layers');
+ });
+
+ it('when totalSize is missing', () => {
+ mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', layers: 10 } });
+
+ expect(findSize().text()).toMatchInterpolatedText(`${NOT_AVAILABLE_SIZE} · 10 layers`);
+ });
+
+ it('when layers are missing', () => {
+ mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '1024' } });
+
+ expect(findSize().text()).toMatchInterpolatedText('1.00 KiB');
+ });
+
+ it('when there is 1 layer', () => {
+ mountComponent({ ...defaultProps, tag: { ...tag, totalSize: '0', 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.createdAt });
+ });
+ });
+
+ describe('digest', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findShortRevision().exists()).toBe(true);
+ });
+
+ it('has the correct text', () => {
+ mountComponent();
+
+ expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 2cf3d2f');
+ });
+
+ it(`displays ${NOT_AVAILABLE_TEXT} when digest is missing`, () => {
+ mountComponent({ tag: { ...tag, digest: null } });
+
+ expect(findShortRevision().text()).toMatchInterpolatedText(`Digest: ${NOT_AVAILABLE_TEXT}`);
+ });
+ });
+
+ describe('additional actions menu', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findAdditionalActionsMenu().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+
+ expect(findAdditionalActionsMenu().props()).toMatchObject({
+ icon: 'ellipsis_v',
+ text: 'More actions',
+ textSrOnly: true,
+ category: 'tertiary',
+ right: true,
+ });
+ });
+
+ it.each`
+ canDelete | digest | disabled | buttonDisabled
+ ${true} | ${null} | ${true} | ${true}
+ ${false} | ${'foo'} | ${true} | ${true}
+ ${false} | ${null} | ${true} | ${true}
+ ${true} | ${'foo'} | ${true} | ${true}
+ ${true} | ${'foo'} | ${false} | ${false}
+ `(
+ 'is $visible that is visible when canDelete is $canDelete and digest is $digest and disabled is $disabled',
+ ({ canDelete, digest, disabled, buttonDisabled }) => {
+ mountComponent({ ...defaultProps, tag: { ...tag, canDelete, digest }, disabled });
+
+ expect(findAdditionalActionsMenu().props('disabled')).toBe(buttonDisabled);
+ expect(findAdditionalActionsMenu().classes('gl-opacity-0')).toBe(buttonDisabled);
+ expect(findAdditionalActionsMenu().classes('gl-pointer-events-none')).toBe(buttonDisabled);
+ },
+ );
+
+ describe('delete button', () => {
+ it('exists and has the correct attrs', () => {
+ mountComponent();
+
+ expect(findDeleteButton().exists()).toBe(true);
+ expect(findDeleteButton().attributes()).toMatchObject({
+ variant: 'danger',
+ });
+ expect(findDeleteButton().text()).toBe(REMOVE_TAG_BUTTON_TITLE);
+ });
+
+ it('delete event emits delete', () => {
+ mountComponent();
+
+ findDeleteButton().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')).toEqual([[]]);
+ });
+ });
+ });
+
+ describe('details rows', () => {
+ describe('when the tag has a digest', () => {
+ it('has 3 details rows', async () => {
+ mountComponent();
+ await nextTick();
+
+ expect(findDetailsRows().length).toBe(3);
+ });
+
+ describe.each`
+ name | finderFunction | text | icon | clipboard
+ ${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the gitlab-org/gitlab-test/rails-12009 image repository at 01:29 UTC on 2020-11-03'} | ${'clock'} | ${false}
+ ${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062'} | ${'log'} | ${true}
+ ${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b'} | ${'cloud-gear'} | ${true}
+ `('$name details row', ({ finderFunction, text, icon, clipboard }) => {
+ it(`has ${text} as text`, async () => {
+ mountComponent();
+ await nextTick();
+
+ expect(finderFunction().text()).toMatchInterpolatedText(text);
+ });
+
+ it(`has the ${icon} icon`, async () => {
+ mountComponent();
+ await nextTick();
+
+ expect(finderFunction().props('icon')).toBe(icon);
+ });
+
+ if (clipboard) {
+ it(`clipboard button exist`, async () => {
+ mountComponent();
+ await nextTick();
+
+ expect(finderFunction().find(ClipboardButton).exists()).toBe(clipboard);
+ });
+
+ it('is disabled when the component is disabled', async () => {
+ mountComponent({ ...defaultProps, disabled: true });
+ await nextTick();
+
+ expect(finderFunction().findComponent(ClipboardButton).attributes('disabled')).toBe(
+ 'true',
+ );
+ });
+ }
+ });
+ });
+
+ describe('when the tag does not have a digest', () => {
+ it('hides the details rows', async () => {
+ mountComponent({ tag: { ...tag, digest: null } });
+
+ await nextTick();
+ expect(findDetailsRows().length).toBe(0);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
new file mode 100644
index 00000000000..9a42c82d7e0
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_list_spec.js
@@ -0,0 +1,314 @@
+import { GlButton, GlKeysetPagination } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
+import TagsListRow from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list_row.vue';
+import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue';
+import {
+ TAGS_LIST_TITLE,
+ REMOVE_TAGS_BUTTON_TITLE,
+} from '~/packages_and_registries/container_registry/explorer/constants/index';
+import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
+import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data';
+
+const localVue = createLocalVue();
+
+describe('Tags List', () => {
+ let wrapper;
+ let apolloProvider;
+ const tags = [...tagsMock];
+ const readOnlyTags = tags.map((t) => ({ ...t, canDelete: false }));
+
+ const findTagsListRow = () => wrapper.findAll(TagsListRow);
+ const findDeleteButton = () => wrapper.find(GlButton);
+ const findListTitle = () => wrapper.find('[data-testid="list-title"]');
+ const findPagination = () => wrapper.find(GlKeysetPagination);
+ const findEmptyState = () => wrapper.find(EmptyTagsState);
+ const findTagsLoader = () => wrapper.find(TagsLoader);
+
+ const waitForApolloRequestRender = async () => {
+ await waitForPromises();
+ await nextTick();
+ };
+
+ const mountComponent = ({
+ propsData = { isMobile: false, id: 1 },
+ resolver = jest.fn().mockResolvedValue(imageTagsMock()),
+ } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]];
+
+ apolloProvider = createMockApollo(requestHandlers);
+ wrapper = shallowMount(component, {
+ localVue,
+ apolloProvider,
+ propsData,
+ provide() {
+ return {
+ config: {},
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('List title', () => {
+ it('exists', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findListTitle().exists()).toBe(true);
+ });
+
+ it('has the correct text', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findListTitle().text()).toBe(TAGS_LIST_TITLE);
+ });
+ });
+
+ describe('delete button', () => {
+ it.each`
+ inputTags | isMobile | isVisible
+ ${tags} | ${false} | ${true}
+ ${tags} | ${true} | ${false}
+ ${readOnlyTags} | ${false} | ${false}
+ ${readOnlyTags} | ${true} | ${false}
+ `(
+ 'is $isVisible that delete button exists when tags is $inputTags and isMobile is $isMobile',
+ async ({ inputTags, isMobile, isVisible }) => {
+ mountComponent({
+ propsData: { tags: inputTags, isMobile, id: 1 },
+ resolver: jest.fn().mockResolvedValue(imageTagsMock(inputTags)),
+ });
+
+ await waitForApolloRequestRender();
+
+ expect(findDeleteButton().exists()).toBe(isVisible);
+ },
+ );
+
+ it('has the correct text', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE);
+ });
+
+ it('has the correct props', async () => {
+ mountComponent();
+ await waitForApolloRequestRender();
+
+ expect(findDeleteButton().attributes()).toMatchObject({
+ category: 'secondary',
+ variant: 'danger',
+ });
+ });
+
+ it.each`
+ disabled | doSelect | buttonDisabled
+ ${true} | ${false} | ${'true'}
+ ${true} | ${true} | ${'true'}
+ ${false} | ${false} | ${'true'}
+ ${false} | ${true} | ${undefined}
+ `(
+ 'is $buttonDisabled that the button is disabled when the component disabled state is $disabled and is $doSelect that the user selected a tag',
+ async ({ disabled, buttonDisabled, doSelect }) => {
+ mountComponent({ propsData: { tags, disabled, isMobile: false, id: 1 } });
+
+ await waitForApolloRequestRender();
+
+ if (doSelect) {
+ findTagsListRow().at(0).vm.$emit('select');
+ await nextTick();
+ }
+
+ expect(findDeleteButton().attributes('disabled')).toBe(buttonDisabled);
+ },
+ );
+
+ it('click event emits a deleted event with selected items', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ findTagsListRow().at(0).vm.$emit('select');
+ findDeleteButton().vm.$emit('click');
+
+ expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name);
+ });
+ });
+
+ describe('list rows', () => {
+ it('one row exist for each tag', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findTagsListRow()).toHaveLength(tags.length);
+ });
+
+ it('the correct props are bound to it', async () => {
+ mountComponent({ propsData: { disabled: true, id: 1 } });
+
+ await waitForApolloRequestRender();
+
+ const rows = findTagsListRow();
+
+ expect(rows.at(0).attributes()).toMatchObject({
+ first: 'true',
+ disabled: 'true',
+ });
+ });
+
+ describe('events', () => {
+ it('select event update the selected items', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ findTagsListRow().at(0).vm.$emit('select');
+
+ await nextTick();
+
+ expect(findTagsListRow().at(0).attributes('selected')).toBe('true');
+ });
+
+ it('delete event emit a delete event', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ findTagsListRow().at(0).vm.$emit('delete');
+ expect(wrapper.emitted('delete')[0][0][0].name).toBe(tags[0].name);
+ });
+ });
+ });
+
+ describe('when the list of tags is empty', () => {
+ const resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
+
+ it('has the empty state', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('does not show the loader', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findTagsLoader().exists()).toBe(false);
+ });
+
+ it('does not show the list', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findTagsListRow().exists()).toBe(false);
+ expect(findListTitle().exists()).toBe(false);
+ });
+ });
+
+ describe('pagination', () => {
+ it('exists', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it('is hidden when loading', () => {
+ mountComponent();
+
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ it('is hidden when there are no more pages', async () => {
+ mountComponent({ resolver: jest.fn().mockResolvedValue(imageTagsMock([])) });
+
+ await waitForApolloRequestRender();
+
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ it('is wired to the correct pagination props', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findPagination().props()).toMatchObject({
+ hasNextPage: tagsPageInfo.hasNextPage,
+ hasPreviousPage: tagsPageInfo.hasPreviousPage,
+ });
+ });
+
+ it('fetch next page when user clicks next', async () => {
+ const resolver = jest.fn().mockResolvedValue(imageTagsMock());
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findPagination().vm.$emit('next');
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ after: tagsPageInfo.endCursor }),
+ );
+ });
+
+ it('fetch previous page when user clicks prev', async () => {
+ const resolver = jest.fn().mockResolvedValue(imageTagsMock());
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findPagination().vm.$emit('prev');
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }),
+ );
+ });
+ });
+
+ describe('loading state', () => {
+ it.each`
+ isImageLoading | queryExecuting | loadingVisible
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ ${false} | ${false} | ${false}
+ `(
+ 'when the isImageLoading is $isImageLoading, and is $queryExecuting that the query is still executing is $loadingVisible that the loader is shown',
+ async ({ isImageLoading, queryExecuting, loadingVisible }) => {
+ mountComponent({ propsData: { isImageLoading, isMobile: false, id: 1 } });
+
+ if (!queryExecuting) {
+ await waitForApolloRequestRender();
+ }
+
+ expect(findTagsLoader().exists()).toBe(loadingVisible);
+ expect(findTagsListRow().exists()).toBe(!loadingVisible);
+ expect(findListTitle().exists()).toBe(!loadingVisible);
+ expect(findPagination().exists()).toBe(!loadingVisible);
+ },
+ );
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js
new file mode 100644
index 00000000000..060dc9dc5f3
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/details_page/tags_loader_spec.js
@@ -0,0 +1,45 @@
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue';
+import { GlSkeletonLoader } from '../../stubs';
+
+describe('TagsLoader component', () => {
+ let wrapper;
+
+ const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader);
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ stubs: {
+ GlSkeletonLoader,
+ },
+ // set the repeat to 1 to avoid a long and verbose snapshot
+ loader: {
+ ...component.loader,
+ repeat: 1,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('produces the correct amount of loaders ', () => {
+ mountComponent();
+ expect(findGlSkeletonLoaders().length).toBe(1);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+ expect(findGlSkeletonLoaders().at(0).props()).toMatchObject({
+ width: component.loader.width,
+ height: component.loader.height,
+ });
+ });
+
+ it('has the correct markup', () => {
+ mountComponent();
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap
new file mode 100644
index 00000000000..56579847468
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/group_empty_state_spec.js.snap
@@ -0,0 +1,15 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Registry Group Empty state to match the default snapshot 1`] = `
+<div>
+ <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"
+ target="_blank"
+ >
+ More Information
+ </gl-link-stub>
+ </p>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap
new file mode 100644
index 00000000000..46b07b4c2d6
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/__snapshots__/project_empty_state_spec.js.snap
@@ -0,0 +1,83 @@
+// Jest Snapshot v1, https://goo.gl/fbAQLP
+
+exports[`Registry Project Empty state to match the default snapshot 1`] = `
+<div>
+ <p>
+ With the Container Registry, every project can have its own space to store its Docker images.
+ <gl-link-stub
+ href="baz"
+ target="_blank"
+ >
+ More Information
+ </gl-link-stub>
+ </p>
+
+ <h5>
+ CLI Commands
+ </h5>
+
+ <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"
+ target="_blank"
+ >
+ Two-Factor Authentication
+ </gl-link-stub>
+ enabled, use a
+ <gl-link-stub
+ href="fooBaz"
+ target="_blank"
+ >
+ Personal Access Token
+ </gl-link-stub>
+ instead of a password.
+ </p>
+
+ <gl-form-input-group-stub
+ class="gl-mb-4"
+ predefinedoptions="[object Object]"
+ value=""
+ >
+ <gl-form-input-stub
+ class="gl-font-monospace!"
+ readonly=""
+ type="text"
+ value="bazbaz"
+ />
+ </gl-form-input-group-stub>
+
+ <p
+ class="gl-mb-4"
+ >
+
+ You can add an image to this registry with the following commands:
+
+ </p>
+
+ <gl-form-input-group-stub
+ class="gl-mb-4"
+ predefinedoptions="[object Object]"
+ value=""
+ >
+ <gl-form-input-stub
+ class="gl-font-monospace!"
+ readonly=""
+ type="text"
+ value="foofoo"
+ />
+ </gl-form-input-group-stub>
+
+ <gl-form-input-group-stub
+ predefinedoptions="[object Object]"
+ value=""
+ >
+ <gl-form-input-stub
+ class="gl-font-monospace!"
+ readonly=""
+ type="text"
+ value="barbar"
+ />
+ </gl-form-input-group-stub>
+</div>
+`;
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
new file mode 100644
index 00000000000..e8ddad2d8ca
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status_spec.js
@@ -0,0 +1,87 @@
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { shallowMountExtended } from 'helpers/vue_test_utils_helper';
+import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue';
+import {
+ CLEANUP_TIMED_OUT_ERROR_MESSAGE,
+ CLEANUP_STATUS_SCHEDULED,
+ CLEANUP_STATUS_ONGOING,
+ CLEANUP_STATUS_UNFINISHED,
+ UNFINISHED_STATUS,
+ UNSCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ONGOING_STATUS,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+
+describe('cleanup_status', () => {
+ let wrapper;
+
+ const findMainIcon = () => wrapper.findByTestId('main-icon');
+ const findExtraInfoIcon = () => wrapper.findByTestId('extra-info');
+
+ const mountComponent = (propsData = { status: SCHEDULED_STATUS }) => {
+ wrapper = shallowMountExtended(CleanupStatus, {
+ propsData,
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it.each`
+ status | visible | text
+ ${UNFINISHED_STATUS} | ${true} | ${CLEANUP_STATUS_UNFINISHED}
+ ${SCHEDULED_STATUS} | ${true} | ${CLEANUP_STATUS_SCHEDULED}
+ ${ONGOING_STATUS} | ${true} | ${CLEANUP_STATUS_ONGOING}
+ ${UNSCHEDULED_STATUS} | ${false} | ${''}
+ `(
+ 'when the status is $status is $visible that the component is mounted and has the correct text',
+ ({ status, visible, text }) => {
+ mountComponent({ status });
+
+ expect(findMainIcon().exists()).toBe(visible);
+ expect(wrapper.text()).toBe(text);
+ },
+ );
+
+ describe('main icon', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findMainIcon().exists()).toBe(true);
+ });
+
+ it(`has the orange class when the status is ${UNFINISHED_STATUS}`, () => {
+ mountComponent({ status: UNFINISHED_STATUS });
+
+ expect(findMainIcon().classes('gl-text-orange-500')).toBe(true);
+ });
+ });
+
+ describe('extra info icon', () => {
+ it.each`
+ status | visible
+ ${UNFINISHED_STATUS} | ${true}
+ ${SCHEDULED_STATUS} | ${false}
+ ${ONGOING_STATUS} | ${false}
+ `(
+ 'when the status is $status is $visible that the extra icon is visible',
+ ({ status, visible }) => {
+ mountComponent({ status });
+
+ expect(findExtraInfoIcon().exists()).toBe(visible);
+ },
+ );
+
+ it(`has a tooltip`, () => {
+ mountComponent({ status: UNFINISHED_STATUS });
+
+ const tooltip = getBinding(findExtraInfoIcon().element, 'gl-tooltip');
+
+ expect(tooltip.value.title).toBe(CLEANUP_TIMED_OUT_ERROR_MESSAGE);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js
new file mode 100644
index 00000000000..4039fba869b
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/cli_commands_spec.js
@@ -0,0 +1,94 @@
+import { GlDropdown } from '@gitlab/ui';
+import { mount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import QuickstartDropdown from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue';
+import {
+ QUICK_START,
+ LOGIN_COMMAND_LABEL,
+ COPY_LOGIN_TITLE,
+ BUILD_COMMAND_LABEL,
+ COPY_BUILD_TITLE,
+ PUSH_COMMAND_LABEL,
+ COPY_PUSH_TITLE,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import Tracking from '~/tracking';
+import CodeInstruction from '~/vue_shared/components/registry/code_instruction.vue';
+
+import { dockerCommands } from '../../mock_data';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('cli_commands', () => {
+ let wrapper;
+
+ const config = {
+ repositoryUrl: 'foo',
+ registryHostUrlWithPort: 'bar',
+ };
+
+ const findDropdownButton = () => wrapper.find(GlDropdown);
+ const findCodeInstruction = () => wrapper.findAll(CodeInstruction);
+
+ const mountComponent = () => {
+ wrapper = mount(QuickstartDropdown, {
+ localVue,
+ provide() {
+ return {
+ config,
+ ...dockerCommands,
+ };
+ },
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ mountComponent();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ it('shows the correct text on the button', () => {
+ expect(findDropdownButton().text()).toContain(QUICK_START);
+ });
+
+ it('clicking on the dropdown emit a tracking event', () => {
+ findDropdownButton().vm.$emit('shown');
+ expect(Tracking.event).toHaveBeenCalledWith(
+ undefined,
+ 'click_dropdown',
+ expect.objectContaining({ label: 'quickstart_dropdown' }),
+ );
+ });
+
+ describe.each`
+ index | labelText | titleText | command | trackedEvent
+ ${0} | ${LOGIN_COMMAND_LABEL} | ${COPY_LOGIN_TITLE} | ${dockerCommands.dockerLoginCommand} | ${'click_copy_login'}
+ ${1} | ${BUILD_COMMAND_LABEL} | ${COPY_BUILD_TITLE} | ${dockerCommands.dockerBuildCommand} | ${'click_copy_build'}
+ ${2} | ${PUSH_COMMAND_LABEL} | ${COPY_PUSH_TITLE} | ${dockerCommands.dockerPushCommand} | ${'click_copy_push'}
+ `('code instructions at $index', ({ index, labelText, titleText, command, trackedEvent }) => {
+ let codeInstruction;
+
+ beforeEach(() => {
+ codeInstruction = findCodeInstruction().at(index);
+ });
+
+ it('exists', () => {
+ expect(codeInstruction.exists()).toBe(true);
+ });
+
+ it(`has the correct props`, () => {
+ expect(codeInstruction.props()).toMatchObject({
+ label: labelText,
+ instruction: command,
+ copyText: titleText,
+ trackingAction: trackedEvent,
+ trackingLabel: 'quickstart_dropdown',
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js
new file mode 100644
index 00000000000..027cdf732bc
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state_spec.js
@@ -0,0 +1,37 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import groupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue';
+import { GlEmptyState } from '../../stubs';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Registry Group Empty state', () => {
+ let wrapper;
+ const config = {
+ noContainersImage: 'foo',
+ helpPagePath: 'baz',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(groupEmptyState, {
+ localVue,
+ stubs: {
+ GlEmptyState,
+ GlSprintf,
+ },
+ provide() {
+ return { config };
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('to match the default snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
new file mode 100644
index 00000000000..411bef54e40
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_row_spec.js
@@ -0,0 +1,223 @@
+import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
+import { getIdFromGraphQLId } from '~/graphql_shared/utils';
+import DeleteButton from '~/packages_and_registries/container_registry/explorer/components/delete_button.vue';
+import CleanupStatus from '~/packages_and_registries/container_registry/explorer/components/list_page/cleanup_status.vue';
+import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue';
+import {
+ ROW_SCHEDULED_FOR_DELETION,
+ LIST_DELETE_BUTTON_DISABLED,
+ REMOVE_REPOSITORY_LABEL,
+ IMAGE_DELETE_SCHEDULED_STATUS,
+ SCHEDULED_STATUS,
+ ROOT_IMAGE_TEXT,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
+import ListItem from '~/vue_shared/components/registry/list_item.vue';
+import { imagesListResponse } from '../../mock_data';
+import { RouterLink } from '../../stubs';
+
+describe('Image List Row', () => {
+ let wrapper;
+ const [item] = imagesListResponse;
+
+ const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
+ const findTagsCount = () => wrapper.find('[data-testid="tags-count"]');
+ const findDeleteBtn = () => wrapper.findComponent(DeleteButton);
+ const findClipboardButton = () => wrapper.findComponent(ClipboardButton);
+ const findCleanupStatus = () => wrapper.findComponent(CleanupStatus);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+ const findListItemComponent = () => wrapper.findComponent(ListItem);
+
+ const mountComponent = (props) => {
+ wrapper = shallowMount(Component, {
+ stubs: {
+ RouterLink,
+ GlSprintf,
+ ListItem,
+ },
+ propsData: {
+ item,
+ ...props,
+ },
+ directives: {
+ GlTooltip: createMockDirective(),
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('list item component', () => {
+ describe('tooltip', () => {
+ it(`the title is ${ROW_SCHEDULED_FOR_DELETION}`, () => {
+ mountComponent();
+
+ const tooltip = getBinding(wrapper.element, 'gl-tooltip');
+ expect(tooltip).toBeDefined();
+ expect(tooltip.value.title).toBe(ROW_SCHEDULED_FOR_DELETION);
+ });
+
+ it('is disabled when item is being deleted', () => {
+ mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
+
+ const tooltip = getBinding(wrapper.element, 'gl-tooltip');
+ expect(tooltip.value.disabled).toBe(false);
+ });
+ });
+
+ it('is disabled when the item is in deleting status', () => {
+ mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
+
+ expect(findListItemComponent().props('disabled')).toBe(true);
+ });
+ });
+
+ describe('image title and path', () => {
+ it('contains a link to the details page', () => {
+ mountComponent();
+
+ const link = findDetailsLink();
+ expect(link.text()).toBe(item.path);
+ expect(findDetailsLink().props('to')).toMatchObject({
+ name: 'details',
+ params: {
+ id: getIdFromGraphQLId(item.id),
+ },
+ });
+ });
+
+ it(`when the image has no name appends ${ROOT_IMAGE_TEXT} to the path`, () => {
+ mountComponent({ item: { ...item, name: '' } });
+
+ expect(findDetailsLink().text()).toBe(`${item.path}/ ${ROOT_IMAGE_TEXT}`);
+ });
+
+ it('contains a clipboard button', () => {
+ mountComponent();
+ const button = findClipboardButton();
+ expect(button.exists()).toBe(true);
+ expect(button.props('text')).toBe(item.location);
+ expect(button.props('title')).toBe(item.location);
+ });
+
+ describe('cleanup status component', () => {
+ it.each`
+ expirationPolicyCleanupStatus | shown
+ ${null} | ${false}
+ ${SCHEDULED_STATUS} | ${true}
+ `(
+ 'when expirationPolicyCleanupStatus is $expirationPolicyCleanupStatus it is $shown that the component exists',
+ ({ expirationPolicyCleanupStatus, shown }) => {
+ mountComponent({ item: { ...item, expirationPolicyCleanupStatus } });
+
+ expect(findCleanupStatus().exists()).toBe(shown);
+
+ if (shown) {
+ expect(findCleanupStatus().props()).toMatchObject({
+ status: expirationPolicyCleanupStatus,
+ });
+ }
+ },
+ );
+ });
+
+ describe('when the item is deleting', () => {
+ beforeEach(() => {
+ mountComponent({ item: { ...item, status: IMAGE_DELETE_SCHEDULED_STATUS } });
+ });
+
+ it('the router link is disabled', () => {
+ // we check the event prop as is the only workaround to disable a router link
+ expect(findDetailsLink().props('event')).toBe('');
+ });
+ it('the clipboard button is disabled', () => {
+ expect(findClipboardButton().attributes('disabled')).toBe('true');
+ });
+ });
+ });
+
+ describe('delete button', () => {
+ it('exists', () => {
+ mountComponent();
+ expect(findDeleteBtn().exists()).toBe(true);
+ });
+
+ it('has the correct props', () => {
+ mountComponent();
+
+ expect(findDeleteBtn().props()).toMatchObject({
+ title: REMOVE_REPOSITORY_LABEL,
+ tooltipDisabled: item.canDelete,
+ tooltipTitle: LIST_DELETE_BUTTON_DISABLED,
+ });
+ });
+
+ it('emits a delete event', () => {
+ mountComponent();
+
+ findDeleteBtn().vm.$emit('delete');
+ expect(wrapper.emitted('delete')).toEqual([[item]]);
+ });
+
+ it.each`
+ canDelete | status | state
+ ${false} | ${''} | ${true}
+ ${false} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
+ ${true} | ${IMAGE_DELETE_SCHEDULED_STATUS} | ${true}
+ ${true} | ${''} | ${false}
+ `(
+ 'disabled is $state when canDelete is $canDelete and status is $status',
+ ({ canDelete, status, state }) => {
+ mountComponent({ item: { ...item, canDelete, status } });
+
+ expect(findDeleteBtn().props('disabled')).toBe(state);
+ },
+ );
+ });
+
+ describe('tags count', () => {
+ it('exists', () => {
+ mountComponent();
+ expect(findTagsCount().exists()).toBe(true);
+ });
+
+ it('contains a tag icon', () => {
+ mountComponent();
+ const icon = findTagsCount().find(GlIcon);
+ expect(icon.exists()).toBe(true);
+ expect(icon.props('name')).toBe('tag');
+ });
+
+ describe('loading state', () => {
+ it('shows a loader when metadataLoading is true', () => {
+ mountComponent({ metadataLoading: true });
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('hides the tags count while loading', () => {
+ mountComponent({ metadataLoading: true });
+
+ expect(findTagsCount().exists()).toBe(false);
+ });
+ });
+
+ describe('tags count text', () => {
+ it('with one tag in the image', () => {
+ mountComponent({ item: { ...item, tagsCount: 1 } });
+
+ expect(findTagsCount().text()).toMatchInterpolatedText('1 Tag');
+ });
+ it('with more than one tag in the image', () => {
+ mountComponent({ item: { ...item, tagsCount: 3 } });
+
+ expect(findTagsCount().text()).toMatchInterpolatedText('3 Tags');
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js
new file mode 100644
index 00000000000..e0119954ed4
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/image_list_spec.js
@@ -0,0 +1,88 @@
+import { GlKeysetPagination } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue';
+import ImageListRow from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list_row.vue';
+
+import { imagesListResponse, pageInfo as defaultPageInfo } from '../../mock_data';
+
+describe('Image List', () => {
+ let wrapper;
+
+ const findRow = () => wrapper.findAll(ImageListRow);
+ const findPagination = () => wrapper.find(GlKeysetPagination);
+
+ const mountComponent = (props) => {
+ wrapper = shallowMount(Component, {
+ propsData: {
+ images: imagesListResponse,
+ pageInfo: defaultPageInfo,
+ ...props,
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('list', () => {
+ it('contains one list element for each image', () => {
+ mountComponent();
+
+ expect(findRow().length).toBe(imagesListResponse.length);
+ });
+
+ it('when delete event is emitted on the row it emits up a delete event', () => {
+ mountComponent();
+
+ findRow().at(0).vm.$emit('delete', 'foo');
+ expect(wrapper.emitted('delete')).toEqual([['foo']]);
+ });
+
+ it('passes down the metadataLoading prop', () => {
+ mountComponent({ metadataLoading: true });
+ expect(findRow().at(0).props('metadataLoading')).toBe(true);
+ });
+ });
+
+ describe('pagination', () => {
+ it('exists', () => {
+ mountComponent();
+
+ expect(findPagination().exists()).toBe(true);
+ });
+
+ it.each`
+ hasNextPage | hasPreviousPage | isVisible
+ ${true} | ${true} | ${true}
+ ${true} | ${false} | ${true}
+ ${false} | ${true} | ${true}
+ `(
+ 'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible',
+ ({ hasNextPage, hasPreviousPage, isVisible }) => {
+ mountComponent({ pageInfo: { ...defaultPageInfo, hasNextPage, hasPreviousPage } });
+
+ expect(findPagination().exists()).toBe(isVisible);
+ expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage);
+ expect(findPagination().props('hasNextPage')).toBe(hasNextPage);
+ },
+ );
+
+ it('emits "prev-page" when the user clicks the back page button', () => {
+ mountComponent();
+
+ findPagination().vm.$emit('prev');
+
+ expect(wrapper.emitted('prev-page')).toEqual([[]]);
+ });
+
+ it('emits "next-page" when the user clicks the forward page button', () => {
+ mountComponent();
+
+ findPagination().vm.$emit('next');
+
+ expect(wrapper.emitted('next-page')).toEqual([[]]);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js
new file mode 100644
index 00000000000..21748ae2813
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state_spec.js
@@ -0,0 +1,45 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import Vuex from 'vuex';
+import projectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue';
+import { dockerCommands } from '../../mock_data';
+import { GlEmptyState } from '../../stubs';
+
+const localVue = createLocalVue();
+localVue.use(Vuex);
+
+describe('Registry Project Empty state', () => {
+ let wrapper;
+ const config = {
+ repositoryUrl: 'foo',
+ registryHostUrlWithPort: 'bar',
+ helpPagePath: 'baz',
+ twoFactorAuthHelpLink: 'barBaz',
+ personalAccessTokensHelpLink: 'fooBaz',
+ noContainersImage: 'bazFoo',
+ };
+
+ beforeEach(() => {
+ wrapper = shallowMount(projectEmptyState, {
+ localVue,
+ stubs: {
+ GlEmptyState,
+ GlSprintf,
+ },
+ provide() {
+ return {
+ config,
+ ...dockerCommands,
+ };
+ },
+ });
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('to match the default snapshot', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
new file mode 100644
index 00000000000..92cfeb7633e
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/list_page/registry_header_spec.js
@@ -0,0 +1,135 @@
+import { GlSprintf } from '@gitlab/ui';
+import { shallowMount } from '@vue/test-utils';
+import Component from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue';
+import {
+ CONTAINER_REGISTRY_TITLE,
+ LIST_INTRO_TEXT,
+ EXPIRATION_POLICY_DISABLED_TEXT,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+jest.mock('~/lib/utils/datetime_utility', () => ({
+ approximateDuration: jest.fn(),
+ calculateRemainingMilliseconds: jest.fn(),
+}));
+
+describe('registry_header', () => {
+ let wrapper;
+
+ const findTitleArea = () => wrapper.find(TitleArea);
+ const findCommandsSlot = () => wrapper.find('[data-testid="commands-slot"]');
+ const findImagesCountSubHeader = () => wrapper.find('[data-testid="images-count"]');
+ const findExpirationPolicySubHeader = () => wrapper.find('[data-testid="expiration-policy"]');
+
+ const mountComponent = (propsData, slots) => {
+ wrapper = shallowMount(Component, {
+ stubs: {
+ GlSprintf,
+ TitleArea,
+ },
+ propsData,
+ slots,
+ });
+ return wrapper.vm.$nextTick();
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('header', () => {
+ it('has a title', () => {
+ mountComponent({ metadataLoading: true });
+
+ expect(findTitleArea().props()).toMatchObject({
+ title: CONTAINER_REGISTRY_TITLE,
+ metadataLoading: true,
+ });
+ });
+
+ it('has a commands slot', () => {
+ mountComponent(null, { commands: '<div data-testid="commands-slot">baz</div>' });
+
+ expect(findCommandsSlot().text()).toBe('baz');
+ });
+
+ describe('sub header parts', () => {
+ describe('images count', () => {
+ it('exists', async () => {
+ await mountComponent({ imagesCount: 1 });
+
+ expect(findImagesCountSubHeader().exists()).toBe(true);
+ });
+
+ it('when there is one image', async () => {
+ await mountComponent({ imagesCount: 1 });
+
+ expect(findImagesCountSubHeader().props()).toMatchObject({
+ text: '1 Image repository',
+ icon: 'container-image',
+ });
+ });
+
+ it('when there is more than one image', async () => {
+ await mountComponent({ imagesCount: 3 });
+
+ expect(findImagesCountSubHeader().props('text')).toBe('3 Image repositories');
+ });
+ });
+
+ describe('expiration policy', () => {
+ it('when is disabled', async () => {
+ await mountComponent({
+ expirationPolicy: { enabled: false },
+ expirationPolicyHelpPagePath: 'foo',
+ imagesCount: 1,
+ });
+
+ const text = findExpirationPolicySubHeader();
+ expect(text.exists()).toBe(true);
+ expect(text.props()).toMatchObject({
+ text: EXPIRATION_POLICY_DISABLED_TEXT,
+ icon: 'expire',
+ size: 'xl',
+ });
+ });
+
+ it('when is enabled', async () => {
+ await mountComponent({
+ expirationPolicy: { enabled: true },
+ expirationPolicyHelpPagePath: 'foo',
+ imagesCount: 1,
+ });
+
+ const text = findExpirationPolicySubHeader();
+ expect(text.exists()).toBe(true);
+ expect(text.props('text')).toBe('Expiration policy will run in ');
+ });
+ it('when the expiration policy is completely disabled', async () => {
+ await mountComponent({
+ expirationPolicy: { enabled: true },
+ expirationPolicyHelpPagePath: 'foo',
+ imagesCount: 1,
+ hideExpirationPolicyData: true,
+ });
+
+ const text = findExpirationPolicySubHeader();
+ expect(text.exists()).toBe(false);
+ });
+ });
+ });
+ });
+
+ describe('info messages', () => {
+ describe('default message', () => {
+ it('is correctly bound to title_area props', () => {
+ mountComponent({ helpPagePath: 'foo' });
+
+ expect(findTitleArea().props('infoMessages')).toEqual([
+ { text: LIST_INTRO_TEXT, link: 'foo' },
+ ]);
+ });
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js
new file mode 100644
index 00000000000..e5a8438f23f
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/components/registry_breadcrumb_spec.js
@@ -0,0 +1,78 @@
+import { mount } from '@vue/test-utils';
+
+import component from '~/packages_and_registries/container_registry/explorer/components/registry_breadcrumb.vue';
+
+describe('Registry Breadcrumb', () => {
+ let wrapper;
+ const nameGenerator = jest.fn();
+
+ const routes = [
+ { name: 'list', path: '/', meta: { nameGenerator, root: true } },
+ { name: 'details', path: '/:id', meta: { nameGenerator } },
+ ];
+
+ const mountComponent = ($route) => {
+ wrapper = mount(component, {
+ mocks: {
+ $route,
+ $router: {
+ options: {
+ routes,
+ },
+ },
+ },
+ });
+ };
+
+ beforeEach(() => {
+ nameGenerator.mockClear();
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when is rootRoute', () => {
+ beforeEach(() => {
+ mountComponent(routes[0]);
+ });
+
+ it('renders', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('contains only a single router-link to list', () => {
+ const links = wrapper.findAll('a');
+
+ expect(links).toHaveLength(1);
+ expect(links.at(0).attributes('href')).toBe('/');
+ });
+
+ it('the link text is calculated by nameGenerator', () => {
+ expect(nameGenerator).toHaveBeenCalledTimes(1);
+ });
+ });
+
+ describe('when is not rootRoute', () => {
+ beforeEach(() => {
+ mountComponent(routes[1]);
+ });
+
+ it('renders', () => {
+ expect(wrapper.element).toMatchSnapshot();
+ });
+
+ it('contains two router-links to list and details', () => {
+ const links = wrapper.findAll('a');
+
+ expect(links).toHaveLength(2);
+ expect(links.at(0).attributes('href')).toBe('/');
+ expect(links.at(1).attributes('href')).toBe('#');
+ });
+
+ it('the link text is calculated by nameGenerator', () => {
+ expect(nameGenerator).toHaveBeenCalledTimes(2);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
new file mode 100644
index 00000000000..6a835a28807
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/mock_data.js
@@ -0,0 +1,269 @@
+export const imagesListResponse = [
+ {
+ __typename: 'ContainerRepository',
+ id: 'gid://gitlab/ContainerRepository/26',
+ name: 'rails-12009',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ status: null,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
+ canDelete: true,
+ createdAt: '2020-11-03T13:29:21Z',
+ expirationPolicyStartedAt: null,
+ expirationPolicyCleanupStatus: 'UNSCHEDULED',
+ },
+ {
+ __typename: 'ContainerRepository',
+ id: 'gid://gitlab/ContainerRepository/11',
+ name: 'rails-20572',
+ path: 'gitlab-org/gitlab-test/rails-20572',
+ status: null,
+ location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
+ canDelete: true,
+ createdAt: '2020-09-21T06:57:43Z',
+ expirationPolicyStartedAt: null,
+ expirationPolicyCleanupStatus: 'UNSCHEDULED',
+ },
+];
+
+export const pageInfo = {
+ hasNextPage: true,
+ hasPreviousPage: true,
+ startCursor: 'eyJpZCI6IjI2In0',
+ endCursor: 'eyJpZCI6IjgifQ',
+ __typename: 'ContainerRepositoryConnection',
+};
+
+export const graphQLImageListMock = {
+ data: {
+ project: {
+ __typename: 'Project',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: imagesListResponse,
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const graphQLEmptyImageListMock = {
+ data: {
+ project: {
+ __typename: 'Project',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: [],
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const graphQLEmptyGroupImageListMock = {
+ data: {
+ group: {
+ __typename: 'Group',
+ containerRepositoriesCount: 2,
+ containerRepositories: {
+ __typename: 'ContainerRepositoryConnection',
+ nodes: [],
+ pageInfo,
+ },
+ },
+ },
+};
+
+export const deletedContainerRepository = {
+ id: 'gid://gitlab/ContainerRepository/11',
+ status: 'DELETE_SCHEDULED',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ __typename: 'ContainerRepository',
+};
+
+export const graphQLImageDeleteMock = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ ...deletedContainerRepository,
+ },
+ errors: [],
+ __typename: 'DestroyContainerRepositoryPayload',
+ },
+ },
+};
+
+export const graphQLImageDeleteMockError = {
+ data: {
+ destroyContainerRepository: {
+ containerRepository: {
+ ...deletedContainerRepository,
+ },
+ errors: ['foo'],
+ __typename: 'DestroyContainerRepositoryPayload',
+ },
+ },
+};
+
+export const containerRepositoryMock = {
+ id: 'gid://gitlab/ContainerRepository/26',
+ name: 'rails-12009',
+ path: 'gitlab-org/gitlab-test/rails-12009',
+ status: null,
+ location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009',
+ canDelete: true,
+ createdAt: '2020-11-03T13:29:21Z',
+ updatedAt: '2020-11-03T13:29:21Z',
+ expirationPolicyStartedAt: null,
+ expirationPolicyCleanupStatus: 'UNSCHEDULED',
+ project: {
+ visibility: 'public',
+ path: 'gitlab-test',
+ containerExpirationPolicy: {
+ enabled: false,
+ nextRunAt: '2020-11-27T08:59:27Z',
+ },
+ __typename: 'Project',
+ },
+};
+
+export const tagsPageInfo = {
+ __typename: 'PageInfo',
+ hasNextPage: true,
+ hasPreviousPage: true,
+ startCursor: 'MQ',
+ endCursor: 'MTA',
+};
+
+export const tagsMock = [
+ {
+ digest: 'sha256:2cf3d2fdac1b04a14301d47d51cb88dcd26714c74f91440eeee99ce399089062',
+ location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-24753',
+ path: 'gitlab-org/gitlab-test/rails-12009:beta-24753',
+ name: 'beta-24753',
+ revision: 'c2613843ab33aabf847965442b13a8b55a56ae28837ce182627c0716eb08c02b',
+ shortRevision: 'c2613843a',
+ createdAt: '2020-11-03T13:29:38+00:00',
+ totalSize: '1099511627776',
+ canDelete: true,
+ __typename: 'ContainerRepositoryTag',
+ },
+ {
+ digest: 'sha256:7f94f97dff89ffd122cafe50cd32329adf682356a7a96f69cbfe313ee589791c',
+ location: 'host.docker.internal:5000/gitlab-org/gitlab-test/rails-12009:beta-31075',
+ path: 'gitlab-org/gitlab-test/rails-12009:beta-31075',
+ name: 'beta-31075',
+ revision: 'df44e7228f0f255c73e35b6f0699624a615f42746e3e8e2e4b3804a6d6fc3292',
+ shortRevision: 'df44e7228',
+ createdAt: '2020-11-03T13:29:32+00:00',
+ totalSize: '536870912000',
+ canDelete: true,
+ __typename: 'ContainerRepositoryTag',
+ },
+];
+
+export const imageTagsMock = (nodes = tagsMock) => ({
+ data: {
+ containerRepository: {
+ id: containerRepositoryMock.id,
+ tags: {
+ nodes,
+ pageInfo: { ...tagsPageInfo },
+ __typename: 'ContainerRepositoryTagConnection',
+ },
+ __typename: 'ContainerRepositoryDetails',
+ },
+ },
+});
+
+export const imageTagsCountMock = (override) => ({
+ data: {
+ containerRepository: {
+ id: containerRepositoryMock.id,
+ tagsCount: 13,
+ ...override,
+ },
+ },
+});
+
+export const graphQLImageDetailsMock = (override) => ({
+ data: {
+ containerRepository: {
+ ...containerRepositoryMock,
+
+ tags: {
+ nodes: tagsMock,
+ pageInfo: { ...tagsPageInfo },
+ __typename: 'ContainerRepositoryTagConnection',
+ },
+ __typename: 'ContainerRepositoryDetails',
+ ...override,
+ },
+ },
+});
+
+export const graphQLImageDetailsEmptyTagsMock = {
+ data: {
+ containerRepository: {
+ ...containerRepositoryMock,
+ tags: {
+ nodes: [],
+ pageInfo: {
+ __typename: 'PageInfo',
+ hasNextPage: false,
+ hasPreviousPage: false,
+ startCursor: '',
+ endCursor: '',
+ },
+ __typename: 'ContainerRepositoryTagConnection',
+ },
+ __typename: 'ContainerRepositoryDetails',
+ },
+ },
+};
+
+export const graphQLDeleteImageRepositoryTagsMock = {
+ data: {
+ destroyContainerRepositoryTags: {
+ deletedTagNames: [],
+ errors: [],
+ __typename: 'DestroyContainerRepositoryTagsPayload',
+ },
+ },
+};
+
+export const dockerCommands = {
+ dockerBuildCommand: 'foofoo',
+ dockerPushCommand: 'barbar',
+ dockerLoginCommand: 'bazbaz',
+};
+
+export const graphQLProjectImageRepositoriesDetailsMock = {
+ data: {
+ project: {
+ containerRepositories: {
+ nodes: [
+ {
+ id: 'gid://gitlab/ContainerRepository/26',
+ tagsCount: 4,
+ __typename: 'ContainerRepository',
+ },
+ {
+ id: 'gid://gitlab/ContainerRepository/11',
+ tagsCount: 1,
+ __typename: 'ContainerRepository',
+ },
+ ],
+ __typename: 'ContainerRepositoryConnection',
+ },
+ __typename: 'Project',
+ },
+ },
+};
+
+export const graphQLEmptyImageDetailsMock = {
+ data: {
+ containerRepository: null,
+ },
+};
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
new file mode 100644
index 00000000000..adc9a64e5c9
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/details_spec.js
@@ -0,0 +1,521 @@
+import { GlKeysetPagination } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import axios from '~/lib/utils/axios_utils';
+import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue';
+import DeleteAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_alert.vue';
+import DetailsHeader from '~/packages_and_registries/container_registry/explorer/components/details_page/details_header.vue';
+import EmptyTagsState from '~/packages_and_registries/container_registry/explorer/components/details_page/empty_state.vue';
+import PartialCleanupAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/partial_cleanup_alert.vue';
+import StatusAlert from '~/packages_and_registries/container_registry/explorer/components/details_page/status_alert.vue';
+import TagsList from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_list.vue';
+import TagsLoader from '~/packages_and_registries/container_registry/explorer/components/details_page/tags_loader.vue';
+
+import {
+ UNFINISHED_STATUS,
+ DELETE_SCHEDULED,
+ ALERT_DANGER_IMAGE,
+ MISSING_OR_DELETED_IMAGE_BREADCRUMB,
+ ROOT_IMAGE_TEXT,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import deleteContainerRepositoryTagsMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository_tags.mutation.graphql';
+import getContainerRepositoryDetailsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_details.query.graphql';
+
+import component from '~/packages_and_registries/container_registry/explorer/pages/details.vue';
+import Tracking from '~/tracking';
+
+import {
+ graphQLImageDetailsMock,
+ graphQLDeleteImageRepositoryTagsMock,
+ containerRepositoryMock,
+ graphQLEmptyImageDetailsMock,
+ tagsMock,
+} from '../mock_data';
+import { DeleteModal } from '../stubs';
+
+const localVue = createLocalVue();
+
+describe('Details Page', () => {
+ let wrapper;
+ let apolloProvider;
+
+ const findDeleteModal = () => wrapper.find(DeleteModal);
+ const findPagination = () => wrapper.find(GlKeysetPagination);
+ const findTagsLoader = () => wrapper.find(TagsLoader);
+ const findTagsList = () => wrapper.find(TagsList);
+ const findDeleteAlert = () => wrapper.find(DeleteAlert);
+ const findDetailsHeader = () => wrapper.find(DetailsHeader);
+ const findEmptyState = () => wrapper.find(EmptyTagsState);
+ const findPartialCleanupAlert = () => wrapper.find(PartialCleanupAlert);
+ const findStatusAlert = () => wrapper.find(StatusAlert);
+ const findDeleteImage = () => wrapper.find(DeleteImage);
+
+ const routeId = 1;
+
+ const breadCrumbState = {
+ updateName: jest.fn(),
+ };
+
+ const cleanTags = tagsMock.map((t) => {
+ const result = { ...t };
+ // eslint-disable-next-line no-underscore-dangle
+ delete result.__typename;
+ return result;
+ });
+
+ const waitForApolloRequestRender = async () => {
+ await waitForPromises();
+ await wrapper.vm.$nextTick();
+ };
+
+ const mountComponent = ({
+ resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()),
+ mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
+ options,
+ config = {},
+ } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [getContainerRepositoryDetailsQuery, resolver],
+ [deleteContainerRepositoryTagsMutation, mutationResolver],
+ ];
+
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMount(component, {
+ localVue,
+ apolloProvider,
+ stubs: {
+ DeleteModal,
+ DeleteImage,
+ },
+ mocks: {
+ $route: {
+ params: {
+ id: routeId,
+ },
+ },
+ },
+ provide() {
+ return {
+ breadCrumbState,
+ config,
+ };
+ },
+ ...options,
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ });
+
+ afterEach(() => {
+ wrapper.destroy();
+ wrapper = null;
+ });
+
+ describe('when isLoading is true', () => {
+ it('shows the loader', () => {
+ mountComponent();
+
+ expect(findTagsLoader().exists()).toBe(true);
+ });
+
+ it('does not show the list', () => {
+ mountComponent();
+
+ expect(findTagsList().exists()).toBe(false);
+ });
+ });
+
+ describe('when the image does not exist', () => {
+ it('does not show the default ui', async () => {
+ mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) });
+
+ await waitForApolloRequestRender();
+
+ expect(findTagsLoader().exists()).toBe(false);
+ expect(findDetailsHeader().exists()).toBe(false);
+ expect(findTagsList().exists()).toBe(false);
+ expect(findPagination().exists()).toBe(false);
+ });
+
+ it('shows an empty state message', async () => {
+ mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) });
+
+ await waitForApolloRequestRender();
+
+ expect(findEmptyState().exists()).toBe(true);
+ });
+ });
+
+ describe('list', () => {
+ it('exists', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findTagsList().exists()).toBe(true);
+ });
+
+ it('has the correct props bound', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findTagsList().props()).toMatchObject({
+ isMobile: false,
+ });
+ });
+
+ describe('deleteEvent', () => {
+ describe('single item', () => {
+ let tagToBeDeleted;
+ beforeEach(async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ [tagToBeDeleted] = cleanTags;
+ findTagsList().vm.$emit('delete', [tagToBeDeleted]);
+ });
+
+ it('open the modal', async () => {
+ expect(DeleteModal.methods.show).toHaveBeenCalled();
+ });
+
+ it('tracks a single delete event', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'registry_tag_delete',
+ });
+ });
+ });
+
+ describe('multiple items', () => {
+ beforeEach(async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ findTagsList().vm.$emit('delete', cleanTags);
+ });
+
+ it('open the modal', () => {
+ expect(DeleteModal.methods.show).toHaveBeenCalled();
+ });
+
+ it('tracks a single delete event', () => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
+ label: 'bulk_registry_tag_delete',
+ });
+ });
+ });
+ });
+ });
+
+ describe('modal', () => {
+ it('exists', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findDeleteModal().exists()).toBe(true);
+ });
+
+ describe('cancel event', () => {
+ it('tracks cancel_delete', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ findDeleteModal().vm.$emit('cancel');
+
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
+ label: 'registry_tag_delete',
+ });
+ });
+ });
+
+ describe('confirmDelete event', () => {
+ let mutationResolver;
+
+ beforeEach(() => {
+ mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock);
+ mountComponent({ mutationResolver });
+
+ return waitForApolloRequestRender();
+ });
+ describe('when one item is selected to be deleted', () => {
+ it('calls apollo mutation with the right parameters', async () => {
+ findTagsList().vm.$emit('delete', [cleanTags[0]]);
+
+ await wrapper.vm.$nextTick();
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: [cleanTags[0].name] }),
+ );
+ });
+ });
+
+ describe('when more than one item is selected to be deleted', () => {
+ it('calls apollo mutation with the right parameters', async () => {
+ findTagsList().vm.$emit('delete', tagsMock);
+
+ await wrapper.vm.$nextTick();
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(mutationResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ tagNames: tagsMock.map((t) => t.name) }),
+ );
+ });
+ });
+ });
+ });
+
+ describe('Header', () => {
+ it('exists', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+ expect(findDetailsHeader().exists()).toBe(true);
+ });
+
+ it('has the correct props', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+ expect(findDetailsHeader().props()).toMatchObject({
+ image: {
+ name: containerRepositoryMock.name,
+ project: {
+ visibility: containerRepositoryMock.project.visibility,
+ },
+ },
+ });
+ });
+ });
+
+ describe('Delete Alert', () => {
+ const config = {
+ isAdmin: true,
+ garbageCollectionHelpPagePath: 'baz',
+ };
+ const deleteAlertType = 'success_tag';
+
+ it('exists', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+ expect(findDeleteAlert().exists()).toBe(true);
+ });
+
+ it('has the correct props', async () => {
+ mountComponent({
+ options: {
+ data: () => ({
+ deleteAlertType,
+ }),
+ },
+ config,
+ });
+
+ await waitForApolloRequestRender();
+
+ expect(findDeleteAlert().props()).toEqual({ ...config, deleteAlertType });
+ });
+ });
+
+ describe('Partial Cleanup Alert', () => {
+ const config = {
+ runCleanupPoliciesHelpPagePath: 'foo',
+ expirationPolicyHelpPagePath: 'bar',
+ userCalloutsPath: 'call_out_path',
+ userCalloutId: 'call_out_id',
+ showUnfinishedTagCleanupCallout: true,
+ };
+
+ describe(`when expirationPolicyCleanupStatus is ${UNFINISHED_STATUS}`, () => {
+ let resolver;
+
+ beforeEach(() => {
+ resolver = jest.fn().mockResolvedValue(
+ graphQLImageDetailsMock({
+ expirationPolicyCleanupStatus: UNFINISHED_STATUS,
+ }),
+ );
+ });
+
+ it('exists', async () => {
+ mountComponent({ resolver, config });
+
+ await waitForApolloRequestRender();
+
+ expect(findPartialCleanupAlert().exists()).toBe(true);
+ });
+
+ it('has the correct props', async () => {
+ mountComponent({ resolver, config });
+
+ await waitForApolloRequestRender();
+
+ expect(findPartialCleanupAlert().props()).toEqual({
+ runCleanupPoliciesHelpPagePath: config.runCleanupPoliciesHelpPagePath,
+ cleanupPoliciesHelpPagePath: config.expirationPolicyHelpPagePath,
+ });
+ });
+
+ it('dismiss hides the component', async () => {
+ jest.spyOn(axios, 'post').mockReturnValue();
+
+ mountComponent({ resolver, config });
+
+ await waitForApolloRequestRender();
+
+ expect(findPartialCleanupAlert().exists()).toBe(true);
+
+ findPartialCleanupAlert().vm.$emit('dismiss');
+
+ await wrapper.vm.$nextTick();
+
+ expect(axios.post).toHaveBeenCalledWith(config.userCalloutsPath, {
+ feature_name: config.userCalloutId,
+ });
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+
+ it('is hidden if the callout is dismissed', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+ });
+
+ describe(`when expirationPolicyCleanupStatus is not ${UNFINISHED_STATUS}`, () => {
+ it('the component is hidden', async () => {
+ mountComponent({ config });
+
+ await waitForApolloRequestRender();
+
+ expect(findPartialCleanupAlert().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('Breadcrumb connection', () => {
+ it('when the details are fetched updates the name', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(breadCrumbState.updateName).toHaveBeenCalledWith(containerRepositoryMock.name);
+ });
+
+ it(`when the image is missing set the breadcrumb to ${MISSING_OR_DELETED_IMAGE_BREADCRUMB}`, async () => {
+ mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLEmptyImageDetailsMock) });
+
+ await waitForApolloRequestRender();
+
+ expect(breadCrumbState.updateName).toHaveBeenCalledWith(MISSING_OR_DELETED_IMAGE_BREADCRUMB);
+ });
+
+ it(`when the image has no name set the breadcrumb to ${ROOT_IMAGE_TEXT}`, async () => {
+ mountComponent({
+ resolver: jest
+ .fn()
+ .mockResolvedValue(graphQLImageDetailsMock({ ...containerRepositoryMock, name: null })),
+ });
+
+ await waitForApolloRequestRender();
+
+ expect(breadCrumbState.updateName).toHaveBeenCalledWith(ROOT_IMAGE_TEXT);
+ });
+ });
+
+ describe('when the image has a status different from null', () => {
+ const resolver = jest
+ .fn()
+ .mockResolvedValue(graphQLImageDetailsMock({ status: DELETE_SCHEDULED }));
+ it('disables all the actions', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findDetailsHeader().props('disabled')).toBe(true);
+ expect(findTagsList().props('disabled')).toBe(true);
+ });
+
+ it('shows a status alert', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findStatusAlert().exists()).toBe(true);
+ expect(findStatusAlert().props()).toMatchObject({
+ status: DELETE_SCHEDULED,
+ });
+ });
+ });
+
+ describe('delete the image', () => {
+ const mountComponentAndDeleteImage = async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+ findDetailsHeader().vm.$emit('delete');
+
+ await wrapper.vm.$nextTick();
+ };
+
+ it('on delete event it deletes the image', async () => {
+ await mountComponentAndDeleteImage();
+
+ findDeleteModal().vm.$emit('confirmDelete');
+
+ expect(findDeleteImage().emitted('start')).toEqual([[]]);
+ });
+
+ it('binds the correct props to the modal', async () => {
+ await mountComponentAndDeleteImage();
+
+ expect(findDeleteModal().props()).toMatchObject({
+ itemsToBeDeleted: [{ path: 'gitlab-org/gitlab-test/rails-12009' }],
+ deleteImage: true,
+ });
+ });
+
+ it('binds correctly to delete-image start and end events', async () => {
+ mountComponent();
+
+ findDeleteImage().vm.$emit('start');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findTagsLoader().exists()).toBe(true);
+
+ findDeleteImage().vm.$emit('end');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findTagsLoader().exists()).toBe(false);
+ });
+
+ it('binds correctly to delete-image error event', async () => {
+ mountComponent();
+
+ findDeleteImage().vm.$emit('error');
+
+ await wrapper.vm.$nextTick();
+
+ expect(findDeleteAlert().props('deleteAlertType')).toBe(ALERT_DANGER_IMAGE);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js
new file mode 100644
index 00000000000..5f4cb8969bc
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/index_spec.js
@@ -0,0 +1,24 @@
+import { shallowMount } from '@vue/test-utils';
+import component from '~/packages_and_registries/container_registry/explorer/pages/index.vue';
+
+describe('List Page', () => {
+ let wrapper;
+
+ const findRouterView = () => wrapper.find({ ref: 'router-view' });
+
+ const mountComponent = () => {
+ wrapper = shallowMount(component, {
+ stubs: {
+ RouterView: true,
+ },
+ });
+ };
+
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('has a router view', () => {
+ expect(findRouterView().exists()).toBe(true);
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
new file mode 100644
index 00000000000..051d1e2a169
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/pages/list_spec.js
@@ -0,0 +1,597 @@
+import { GlSkeletonLoader, GlSprintf, GlAlert } from '@gitlab/ui';
+import { shallowMount, createLocalVue } from '@vue/test-utils';
+import { nextTick } from 'vue';
+import VueApollo from 'vue-apollo';
+import createMockApollo from 'helpers/mock_apollo_helper';
+import waitForPromises from 'helpers/wait_for_promises';
+import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
+import CleanupPolicyEnabledAlert from '~/packages_and_registries/shared/components/cleanup_policy_enabled_alert.vue';
+import { FILTERED_SEARCH_TERM } from '~/packages_and_registries/shared/constants';
+import DeleteImage from '~/packages_and_registries/container_registry/explorer/components/delete_image.vue';
+import CliCommands from '~/packages_and_registries/container_registry/explorer/components/list_page/cli_commands.vue';
+import GroupEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/group_empty_state.vue';
+import ImageList from '~/packages_and_registries/container_registry/explorer/components/list_page/image_list.vue';
+import ProjectEmptyState from '~/packages_and_registries/container_registry/explorer/components/list_page/project_empty_state.vue';
+import RegistryHeader from '~/packages_and_registries/container_registry/explorer/components/list_page/registry_header.vue';
+import {
+ DELETE_IMAGE_SUCCESS_MESSAGE,
+ DELETE_IMAGE_ERROR_MESSAGE,
+ SORT_FIELDS,
+} from '~/packages_and_registries/container_registry/explorer/constants';
+import deleteContainerRepositoryMutation from '~/packages_and_registries/container_registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql';
+import getContainerRepositoriesDetails from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
+import component from '~/packages_and_registries/container_registry/explorer/pages/list.vue';
+import Tracking from '~/tracking';
+import RegistrySearch from '~/vue_shared/components/registry/registry_search.vue';
+import TitleArea from '~/vue_shared/components/registry/title_area.vue';
+
+import { $toast } from 'jest/packages_and_registries/shared/mocks';
+import {
+ graphQLImageListMock,
+ graphQLImageDeleteMock,
+ deletedContainerRepository,
+ graphQLEmptyImageListMock,
+ graphQLEmptyGroupImageListMock,
+ pageInfo,
+ graphQLProjectImageRepositoriesDetailsMock,
+ dockerCommands,
+} from '../mock_data';
+import { GlModal, GlEmptyState } from '../stubs';
+
+const localVue = createLocalVue();
+
+describe('List Page', () => {
+ let wrapper;
+ let apolloProvider;
+
+ const findDeleteModal = () => wrapper.findComponent(GlModal);
+ const findSkeletonLoader = () => wrapper.findComponent(GlSkeletonLoader);
+
+ const findEmptyState = () => wrapper.findComponent(GlEmptyState);
+
+ const findCliCommands = () => wrapper.findComponent(CliCommands);
+ const findProjectEmptyState = () => wrapper.findComponent(ProjectEmptyState);
+ const findGroupEmptyState = () => wrapper.findComponent(GroupEmptyState);
+ const findRegistryHeader = () => wrapper.findComponent(RegistryHeader);
+
+ const findDeleteAlert = () => wrapper.findComponent(GlAlert);
+ const findImageList = () => wrapper.findComponent(ImageList);
+ const findRegistrySearch = () => wrapper.findComponent(RegistrySearch);
+ const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
+ const findDeleteImage = () => wrapper.findComponent(DeleteImage);
+ const findCleanupAlert = () => wrapper.findComponent(CleanupPolicyEnabledAlert);
+
+ const waitForApolloRequestRender = async () => {
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+ await nextTick();
+ };
+
+ const mountComponent = ({
+ mocks,
+ resolver = jest.fn().mockResolvedValue(graphQLImageListMock),
+ detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
+ mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
+ config = { isGroupPage: false },
+ query = {},
+ } = {}) => {
+ localVue.use(VueApollo);
+
+ const requestHandlers = [
+ [getContainerRepositoriesQuery, resolver],
+ [getContainerRepositoriesDetails, detailsResolver],
+ [deleteContainerRepositoryMutation, mutationResolver],
+ ];
+
+ apolloProvider = createMockApollo(requestHandlers);
+
+ wrapper = shallowMount(component, {
+ localVue,
+ apolloProvider,
+ stubs: {
+ GlModal,
+ GlEmptyState,
+ GlSprintf,
+ RegistryHeader,
+ TitleArea,
+ DeleteImage,
+ },
+ mocks: {
+ $toast,
+ $route: {
+ name: 'foo',
+ query,
+ },
+ ...mocks,
+ },
+ provide() {
+ return {
+ config,
+ ...dockerCommands,
+ };
+ },
+ });
+ };
+
+ afterEach(() => {
+ wrapper.destroy();
+ });
+
+ it('contains registry header', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findRegistryHeader().exists()).toBe(true);
+ expect(findRegistryHeader().props()).toMatchObject({
+ imagesCount: 2,
+ metadataLoading: false,
+ });
+ });
+
+ describe.each([
+ { error: 'connectionError', errorName: 'connection error' },
+ { error: 'invalidPathError', errorName: 'invalid path error' },
+ ])('handling $errorName', ({ error }) => {
+ const config = {
+ containersErrorImage: 'foo',
+ helpPagePath: 'bar',
+ isGroupPage: false,
+ };
+ config[error] = true;
+
+ it('should show an empty state', () => {
+ mountComponent({ config });
+
+ expect(findEmptyState().exists()).toBe(true);
+ });
+
+ it('empty state should have an svg-path', () => {
+ mountComponent({ config });
+
+ expect(findEmptyState().props('svgPath')).toBe(config.containersErrorImage);
+ });
+
+ it('empty state should have a description', () => {
+ mountComponent({ config });
+
+ expect(findEmptyState().props('title')).toContain('connection error');
+ });
+
+ it('should not show the loading or default state', () => {
+ mountComponent({ config });
+
+ expect(findSkeletonLoader().exists()).toBe(false);
+ expect(findImageList().exists()).toBe(false);
+ });
+ });
+
+ describe('isLoading is true', () => {
+ it('shows the skeleton loader', async () => {
+ mountComponent();
+
+ await nextTick();
+
+ expect(findSkeletonLoader().exists()).toBe(true);
+ });
+
+ it('imagesList is not visible', () => {
+ mountComponent();
+
+ expect(findImageList().exists()).toBe(false);
+ });
+
+ it('cli commands is not visible', () => {
+ mountComponent();
+
+ expect(findCliCommands().exists()).toBe(false);
+ });
+
+ it('title has the metadataLoading props set to true', async () => {
+ mountComponent();
+
+ await nextTick();
+
+ expect(findRegistryHeader().props('metadataLoading')).toBe(true);
+ });
+ });
+
+ describe('list is empty', () => {
+ describe('project page', () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLEmptyImageListMock);
+
+ it('cli commands is not visible', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findCliCommands().exists()).toBe(false);
+ });
+
+ it('project empty state is visible', async () => {
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findProjectEmptyState().exists()).toBe(true);
+ });
+ });
+
+ describe('group page', () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
+
+ const config = {
+ isGroupPage: true,
+ };
+
+ it('group empty state is visible', async () => {
+ mountComponent({ resolver, config });
+
+ await waitForApolloRequestRender();
+
+ expect(findGroupEmptyState().exists()).toBe(true);
+ });
+
+ it('cli commands is not visible', async () => {
+ mountComponent({ resolver, config });
+
+ await waitForApolloRequestRender();
+
+ expect(findCliCommands().exists()).toBe(false);
+ });
+ });
+ });
+
+ describe('list is not empty', () => {
+ describe('unfiltered state', () => {
+ it('quick start is visible', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findCliCommands().exists()).toBe(true);
+ });
+
+ it('list component is visible', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findImageList().exists()).toBe(true);
+ });
+
+ describe('additional metadata', () => {
+ it('is called on component load', async () => {
+ const detailsResolver = jest
+ .fn()
+ .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
+ mountComponent({ detailsResolver });
+
+ jest.runOnlyPendingTimers();
+ await waitForPromises();
+
+ expect(detailsResolver).toHaveBeenCalled();
+ });
+
+ it('does not block the list ui to show', async () => {
+ const detailsResolver = jest.fn().mockRejectedValue();
+ mountComponent({ detailsResolver });
+
+ await waitForApolloRequestRender();
+
+ expect(findImageList().exists()).toBe(true);
+ });
+
+ it('loading state is passed to list component', async () => {
+ // this is a promise that never resolves, to trick apollo to think that this request is still loading
+ const detailsResolver = jest.fn().mockImplementation(() => new Promise(() => {}));
+
+ mountComponent({ detailsResolver });
+ await waitForApolloRequestRender();
+
+ expect(findImageList().props('metadataLoading')).toBe(true);
+ });
+ });
+
+ describe('delete image', () => {
+ const selectImageForDeletion = async () => {
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('delete', deletedContainerRepository);
+ };
+
+ it('should call deleteItem when confirming deletion', async () => {
+ const mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock);
+ mountComponent({ mutationResolver });
+
+ await selectImageForDeletion();
+
+ findDeleteModal().vm.$emit('primary');
+ await waitForApolloRequestRender();
+
+ expect(wrapper.vm.itemToDelete).toEqual(deletedContainerRepository);
+
+ const updatedImage = findImageList()
+ .props('images')
+ .find((i) => i.id === deletedContainerRepository.id);
+
+ expect(updatedImage.status).toBe(deletedContainerRepository.status);
+ });
+
+ it('should show a success alert when delete request is successful', async () => {
+ mountComponent();
+
+ await selectImageForDeletion();
+
+ findDeleteImage().vm.$emit('success');
+ await nextTick();
+
+ const alert = findDeleteAlert();
+ expect(alert.exists()).toBe(true);
+ expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
+ DELETE_IMAGE_SUCCESS_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
+ );
+ });
+
+ describe('when delete request fails it shows an alert', () => {
+ it('user recoverable error', async () => {
+ mountComponent();
+
+ await selectImageForDeletion();
+
+ findDeleteImage().vm.$emit('error');
+ await nextTick();
+
+ const alert = findDeleteAlert();
+ expect(alert.exists()).toBe(true);
+ expect(alert.text().replace(/\s\s+/gm, ' ')).toBe(
+ DELETE_IMAGE_ERROR_MESSAGE.replace('%{title}', wrapper.vm.itemToDelete.path),
+ );
+ });
+ });
+ });
+ });
+
+ describe('search and sorting', () => {
+ const doSearch = async () => {
+ await waitForApolloRequestRender();
+ findRegistrySearch().vm.$emit('filter:changed', [
+ { type: FILTERED_SEARCH_TERM, value: { data: 'centos6' } },
+ ]);
+
+ findRegistrySearch().vm.$emit('filter:submit');
+
+ await nextTick();
+ };
+
+ it('has a search box element', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ const registrySearch = findRegistrySearch();
+ expect(registrySearch.exists()).toBe(true);
+ expect(registrySearch.props()).toMatchObject({
+ filter: [],
+ sorting: { orderBy: 'UPDATED', sort: 'desc' },
+ sortableFields: SORT_FIELDS,
+ tokens: [],
+ });
+ });
+
+ it('performs sorting', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await waitForApolloRequestRender();
+
+ findRegistrySearch().vm.$emit('sorting:changed', { sort: 'asc' });
+ await nextTick();
+
+ expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ sort: 'UPDATED_DESC' }));
+ });
+
+ it('performs a search', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ resolver });
+
+ await doSearch();
+
+ expect(resolver).toHaveBeenCalledWith(expect.objectContaining({ name: 'centos6' }));
+ });
+
+ it('when search result is empty displays an empty search message', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ const detailsResolver = jest
+ .fn()
+ .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
+ mountComponent({ resolver, detailsResolver });
+
+ await waitForApolloRequestRender();
+
+ resolver.mockResolvedValue(graphQLEmptyImageListMock);
+ detailsResolver.mockResolvedValue(graphQLEmptyImageListMock);
+
+ await doSearch();
+
+ expect(findEmptySearchMessage().exists()).toBe(true);
+ });
+ });
+
+ describe('pagination', () => {
+ it('prev-page event triggers a fetchMore request', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ const detailsResolver = jest
+ .fn()
+ .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
+ mountComponent({ resolver, detailsResolver });
+
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('prev-page');
+ await nextTick();
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ before: pageInfo.startCursor }),
+ );
+ expect(detailsResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ before: pageInfo.startCursor }),
+ );
+ });
+
+ it('next-page event triggers a fetchMore request', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ const detailsResolver = jest
+ .fn()
+ .mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
+ mountComponent({ resolver, detailsResolver });
+
+ await waitForApolloRequestRender();
+
+ findImageList().vm.$emit('next-page');
+ await nextTick();
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({ after: pageInfo.endCursor }),
+ );
+ expect(detailsResolver).toHaveBeenCalledWith(
+ expect.objectContaining({ after: pageInfo.endCursor }),
+ );
+ });
+ });
+ });
+
+ describe('modal', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ it('exists', () => {
+ expect(findDeleteModal().exists()).toBe(true);
+ });
+
+ it('contains a description with the path of the item to delete', async () => {
+ findImageList().vm.$emit('delete', { path: 'foo' });
+ await nextTick();
+ expect(findDeleteModal().html()).toContain('foo');
+ });
+ });
+
+ describe('tracking', () => {
+ beforeEach(() => {
+ mountComponent();
+ });
+
+ const testTrackingCall = (action) => {
+ expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
+ label: 'registry_repository_delete',
+ });
+ };
+
+ beforeEach(() => {
+ jest.spyOn(Tracking, 'event');
+ });
+
+ it('send an event when delete button is clicked', () => {
+ findImageList().vm.$emit('delete', {});
+
+ testTrackingCall('click_button');
+ });
+
+ it('send an event when cancel is pressed on modal', () => {
+ const deleteModal = findDeleteModal();
+ deleteModal.vm.$emit('cancel');
+ testTrackingCall('cancel_delete');
+ });
+
+ it('send an event when the deletion starts', () => {
+ findDeleteImage().vm.$emit('start');
+ testTrackingCall('confirm_delete');
+ });
+ });
+
+ describe('url query string handling', () => {
+ const defaultQueryParams = {
+ search: [1, 2],
+ sort: 'asc',
+ orderBy: 'CREATED',
+ };
+ const queryChangePayload = 'foo';
+
+ it('query:updated event pushes the new query to the router', async () => {
+ const push = jest.fn();
+ mountComponent({ mocks: { $router: { push } } });
+
+ await nextTick();
+
+ findRegistrySearch().vm.$emit('query:changed', queryChangePayload);
+
+ expect(push).toHaveBeenCalledWith({ query: queryChangePayload });
+ });
+
+ it('graphql API call has the variables set from the URL', async () => {
+ const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
+ mountComponent({ query: defaultQueryParams, resolver });
+
+ await nextTick();
+
+ expect(resolver).toHaveBeenCalledWith(
+ expect.objectContaining({
+ name: 1,
+ sort: 'CREATED_ASC',
+ }),
+ );
+ });
+
+ it.each`
+ sort | orderBy | search | payload
+ ${'ASC'} | ${undefined} | ${undefined} | ${{ sort: 'UPDATED_ASC' }}
+ ${undefined} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_DESC' }}
+ ${'ASC'} | ${'bar'} | ${undefined} | ${{ sort: 'BAR_ASC' }}
+ ${undefined} | ${undefined} | ${undefined} | ${{}}
+ ${undefined} | ${undefined} | ${['one']} | ${{ name: 'one' }}
+ ${undefined} | ${undefined} | ${['one', 'two']} | ${{ name: 'one' }}
+ ${undefined} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_DESC' }}
+ ${'ASC'} | ${'UPDATED'} | ${['one', 'two']} | ${{ name: 'one', sort: 'UPDATED_ASC' }}
+ `(
+ 'with sort equal to $sort, orderBy equal to $orderBy, search set to $search API call has the variables set as $payload',
+ async ({ sort, orderBy, search, payload }) => {
+ const resolver = jest.fn().mockResolvedValue({ sort, orderBy });
+ mountComponent({ query: { sort, orderBy, search }, resolver });
+
+ await nextTick();
+
+ expect(resolver).toHaveBeenCalledWith(expect.objectContaining(payload));
+ },
+ );
+ });
+
+ describe('cleanup is on alert', () => {
+ it('exist when showCleanupPolicyOnAlert is true and has the correct props', async () => {
+ mountComponent({
+ config: {
+ showCleanupPolicyOnAlert: true,
+ projectPath: 'foo',
+ isGroupPage: false,
+ cleanupPoliciesSettingsPath: 'bar',
+ },
+ });
+
+ await waitForApolloRequestRender();
+
+ expect(findCleanupAlert().exists()).toBe(true);
+ expect(findCleanupAlert().props()).toMatchObject({
+ projectPath: 'foo',
+ cleanupPoliciesSettingsPath: 'bar',
+ });
+ });
+
+ it('is hidden when showCleanupPolicyOnAlert is false', async () => {
+ mountComponent();
+
+ await waitForApolloRequestRender();
+
+ expect(findCleanupAlert().exists()).toBe(false);
+ });
+ });
+});
diff --git a/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js
new file mode 100644
index 00000000000..7d281a53a59
--- /dev/null
+++ b/spec/frontend/packages_and_registries/container_registry/explorer/stubs.js
@@ -0,0 +1,45 @@
+import {
+ GlModal as RealGlModal,
+ GlEmptyState as RealGlEmptyState,
+ GlSkeletonLoader as RealGlSkeletonLoader,
+ GlDropdown as RealGlDropdown,
+} from '@gitlab/ui';
+import { RouterLinkStub } from '@vue/test-utils';
+import { stubComponent } from 'helpers/stub_component';
+import RealDeleteModal from '~/packages_and_registries/container_registry/explorer/components/details_page/delete_modal.vue';
+import RealListItem from '~/vue_shared/components/registry/list_item.vue';
+
+export const GlModal = stubComponent(RealGlModal, {
+ template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
+ methods: {
+ show: jest.fn(),
+ },
+});
+
+export const GlEmptyState = stubComponent(RealGlEmptyState, {
+ template: '<div><slot name="description"></slot></div>',
+});
+
+export const RouterLink = RouterLinkStub;
+
+export const DeleteModal = stubComponent(RealDeleteModal, {
+ methods: {
+ show: jest.fn(),
+ },
+});
+
+export const GlSkeletonLoader = stubComponent(RealGlSkeletonLoader);
+
+export const ListItem = {
+ ...RealListItem,
+ data() {
+ return {
+ detailsSlots: [],
+ isDetailsShown: true,
+ };
+ },
+};
+
+export const GlDropdown = stubComponent(RealGlDropdown, {
+ template: '<div><slot></slot></div>',
+});