Commit edc3beb1 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Frédéric Caplette

Enable bulk delete in tags list

- source
- test

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