Commit a3254abf authored by Frédéric Caplette's avatar Frédéric Caplette

Merge branch '336934-improved-ux-for-bulk-deleting-container-image-tags' into 'master'

Enable bulk delete in container registry tags list

See merge request gitlab-org/gitlab!75655
parents 7eb2f2d7 edc3beb1
<script>
import { GlButton, GlKeysetPagination } from '@gitlab/ui';
import createFlash from '~/flash';
import { n__ } from '~/locale';
import { joinPaths } from '~/lib/utils/url_utility';
import RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
......@@ -16,11 +17,10 @@ import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
components: {
GlButton,
GlKeysetPagination,
TagsListRow,
EmptyState,
TagsLoader,
RegistryList,
},
inject: ['config'],
props: {
......@@ -61,11 +61,13 @@ export default {
},
data() {
return {
selectedItems: {},
containerRepository: {},
};
},
computed: {
listTitle() {
return n__('%d tag', '%d tags', this.tags.length);
},
tags() {
return this.containerRepository?.tags?.nodes || [];
},
......@@ -78,18 +80,9 @@ export default {
first: GRAPHQL_PAGE_SIZE,
};
},
hasSelectedItems() {
return this.tags.some((tag) => this.selectedItems[tag.name]);
},
showMultiDeleteButton() {
return this.tags.some((tag) => tag.canDelete) && !this.isMobile;
},
multiDeleteButtonIsDisabled() {
return !this.hasSelectedItems || this.disabled;
},
showPagination() {
return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
},
hasNoTags() {
return this.tags.length === 0;
},
......@@ -98,19 +91,13 @@ export default {
},
},
methods: {
updateSelectedItems(name) {
this.$set(this.selectedItems, name, !this.selectedItems[name]);
},
mapTagsToBeDleeted(items) {
return this.tags.filter((tag) => items[tag.name]);
},
fetchNextPage() {
this.$apollo.queries.containerRepository.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
});
......@@ -122,7 +109,7 @@ export default {
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
});
......@@ -137,42 +124,27 @@ export default {
<template v-else>
<empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
<template v-else>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<h5 data-testid="list-title">
{{ $options.i18n.TAGS_LIST_TITLE }}
</h5>
<gl-button
v-if="showMultiDeleteButton"
:disabled="multiDeleteButtonIsDisabled"
category="secondary"
variant="danger"
@click="$emit('delete', mapTagsToBeDleeted(selectedItems))"
<registry-list
:title="listTitle"
:pagination="tagsPageInfo"
:items="tags"
id-property="name"
@prev-page="fetchPreviousPage"
@next-page="fetchNextPage"
@delete="$emit('delete', $event)"
>
{{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
</gl-button>
</div>
<template #default="{ selectItem, isSelected, item, first }">
<tags-list-row
v-for="(tag, index) in tags"
:key="tag.path"
:tag="tag"
:first="index === 0"
:selected="selectedItems[tag.name]"
:tag="item"
:first="first"
:selected="isSelected(item)"
:is-mobile="isMobile"
:disabled="disabled"
@select="updateSelectedItems(tag.name)"
@delete="$emit('delete', mapTagsToBeDleeted({ [tag.name]: true }))"
/>
<div class="gl-display-flex gl-justify-content-center">
<gl-keyset-pagination
v-if="showPagination"
:has-next-page="tagsPageInfo.hasNextPage"
:has-previous-page="tagsPageInfo.hasPreviousPage"
class="gl-mt-3"
@prev="fetchPreviousPage"
@next="fetchNextPage"
@select="selectItem(item)"
@delete="$emit('delete', [item])"
/>
</div>
</template>
</registry-list>
</template>
</template>
</div>
......
......@@ -82,7 +82,7 @@ RSpec.describe 'Container Registry', :js do
end
it 'shows the image tags' do
expect(page).to have_content 'Image tags'
expect(page).to have_content '1 tag'
first_tag = first('[data-testid="name"]')
expect(first_tag).to have_content 'latest'
end
......
......@@ -87,7 +87,7 @@ RSpec.describe 'Container Registry', :js do
end
it 'shows the image tags' do
expect(page).to have_content 'Image tags'
expect(page).to have_content '20 tags'
first_tag = first('[data-testid="name"]')
expect(first_tag).to have_content '1'
end
......
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 { stripTypenames } from 'helpers/graphql_helpers';
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 RegistryList from '~/packages_and_registries/shared/components/registry_list.vue';
import getContainerRepositoryTagsQuery from '~/packages_and_registries/container_registry/explorer/graphql/queries/get_container_repository_tags.query.graphql';
import { GRAPHQL_PAGE_SIZE } from '~/packages_and_registries/container_registry/explorer/constants/index';
import { tagsMock, imageTagsMock, tagsPageInfo } from '../../mock_data';
const localVue = createLocalVue();
......@@ -20,25 +18,20 @@ const localVue = createLocalVue();
describe('Tags List', () => {
let wrapper;
let apolloProvider;
let resolver;
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 findTagsListRow = () => wrapper.findAllComponents(TagsListRow);
const findRegistryList = () => wrapper.findComponent(RegistryList);
const findEmptyState = () => wrapper.findComponent(EmptyTagsState);
const findTagsLoader = () => wrapper.findComponent(TagsLoader);
const waitForApolloRequestRender = async () => {
await waitForPromises();
await nextTick();
};
const mountComponent = ({
propsData = { isMobile: false, id: 1 },
resolver = jest.fn().mockResolvedValue(imageTagsMock()),
} = {}) => {
const mountComponent = ({ propsData = { isMobile: false, id: 1 } } = {}) => {
localVue.use(VueApollo);
const requestHandlers = [[getContainerRepositoryTagsQuery, resolver]];
......@@ -48,6 +41,7 @@ describe('Tags List', () => {
localVue,
apolloProvider,
propsData,
stubs: { RegistryList },
provide() {
return {
config: {},
......@@ -56,99 +50,58 @@ describe('Tags List', () => {
});
};
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(imageTagsMock());
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('List title', () => {
it('exists', async () => {
describe('registry list', () => {
beforeEach(() => {
mountComponent();
await waitForApolloRequestRender();
expect(findListTitle().exists()).toBe(true);
return waitForApolloRequestRender();
});
it('has the correct text', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findListTitle().text()).toBe(TAGS_LIST_TITLE);
it('binds the correct props', () => {
expect(findRegistryList().props()).toMatchObject({
title: '2 tags',
pagination: stripTypenames(tagsPageInfo),
items: stripTypenames(tags),
idProperty: 'name',
});
});
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();
describe('events', () => {
it('prev-page fetch the previous page', () => {
findRegistryList().vm.$emit('prev-page');
expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE);
expect(resolver).toHaveBeenCalledWith({
first: null,
before: tagsPageInfo.startCursor,
last: GRAPHQL_PAGE_SIZE,
id: '1',
});
});
it('has the correct props', async () => {
mountComponent();
await waitForApolloRequestRender();
it('next-page fetch the previous page', () => {
findRegistryList().vm.$emit('next-page');
expect(findDeleteButton().attributes()).toMatchObject({
category: 'secondary',
variant: 'danger',
expect(resolver).toHaveBeenCalledWith({
after: tagsPageInfo.endCursor,
first: GRAPHQL_PAGE_SIZE,
id: '1',
});
});
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();
}
it('emits a delete event when list emits delete', () => {
const eventPayload = 'foo';
findRegistryList().vm.$emit('delete', eventPayload);
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);
expect(wrapper.emitted('delete')).toEqual([[eventPayload]]);
});
});
});
......@@ -199,10 +152,12 @@ describe('Tags List', () => {
});
describe('when the list of tags is empty', () => {
const resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
beforeEach(() => {
resolver = jest.fn().mockResolvedValue(imageTagsMock([]));
});
it('has the empty state', async () => {
mountComponent({ resolver });
mountComponent();
await waitForApolloRequestRender();
......@@ -210,7 +165,7 @@ describe('Tags List', () => {
});
it('does not show the loader', async () => {
mountComponent({ resolver });
mountComponent();
await waitForApolloRequestRender();
......@@ -218,76 +173,13 @@ describe('Tags List', () => {
});
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);
expect(findRegistryList().exists()).toBe(false);
});
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
......@@ -306,8 +198,6 @@ describe('Tags List', () => {
expect(findTagsLoader().exists()).toBe(loadingVisible);
expect(findTagsListRow().exists()).toBe(!loadingVisible);
expect(findListTitle().exists()).toBe(!loadingVisible);
expect(findPagination().exists()).toBe(!loadingVisible);
},
);
});
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment