Commit 2a6451c1 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '296962-container-registry-details-split-details-from-tags-call' into 'master'

Container Registry Details: split details from tags call

See merge request gitlab-org/gitlab!59969
parents f0bab9fb 9079b29a
<script>
import { GlButton } from '@gitlab/ui';
import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index';
import { GlButton, GlKeysetPagination } from '@gitlab/ui';
import createFlash from '~/flash';
import { joinPaths } from '~/lib/utils/url_utility';
import {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
} from '../../constants/index';
import getContainerRepositoryTagsQuery from '../../graphql/queries/get_container_repository_tags.query.graphql';
import EmptyState from './empty_state.vue';
import TagsListRow from './tags_list_row.vue';
import TagsLoader from './tags_loader.vue';
export default {
name: 'TagsList',
components: {
GlButton,
GlKeysetPagination,
TagsListRow,
EmptyState,
TagsLoader,
},
inject: ['config'],
props: {
tags: {
type: Array,
required: false,
default: () => [],
id: {
type: [Number, String],
required: true,
},
isMobile: {
type: Boolean,
......@@ -25,17 +38,46 @@ export default {
default: false,
required: false,
},
isImageLoading: {
type: Boolean,
default: false,
required: false,
},
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
},
apollo: {
containerRepository: {
query: getContainerRepositoryTagsQuery,
variables() {
return this.queryVariables;
},
error() {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
},
},
},
data() {
return {
selectedItems: {},
containerRepository: {},
};
},
computed: {
tags() {
return this.containerRepository?.tags?.nodes || [];
},
tagsPageInfo() {
return this.containerRepository?.tags?.pageInfo;
},
queryVariables() {
return {
id: joinPaths(this.config.gidPrefix, `${this.id}`),
first: GRAPHQL_PAGE_SIZE,
};
},
hasSelectedItems() {
return this.tags.some((tag) => this.selectedItems[tag.name]);
},
......@@ -45,42 +87,93 @@ export default {
multiDeleteButtonIsDisabled() {
return !this.hasSelectedItems || this.disabled;
},
showPagination() {
return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
},
hasNoTags() {
return this.tags.length === 0;
},
isLoading() {
return this.isImageLoading || this.$apollo.queries.containerRepository.loading;
},
},
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 }) {
return fetchMoreResult;
},
});
},
fetchPreviousPage() {
this.$apollo.queries.containerRepository.fetchMore({
variables: {
first: null,
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<h5 data-testid="list-title">
{{ $options.i18n.TAGS_LIST_TITLE }}
</h5>
<tags-loader v-if="isLoading" />
<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', selectedItems)"
>
{{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
</gl-button>
</div>
<tags-list-row
v-for="(tag, index) in tags"
:key="tag.path"
:tag="tag"
:first="index === 0"
:selected="selectedItems[tag.name]"
:is-mobile="isMobile"
:disabled="disabled"
@select="updateSelectedItems(tag.name)"
@delete="$emit('delete', { [tag.name]: true })"
/>
<gl-button
v-if="showMultiDeleteButton"
:disabled="multiDeleteButtonIsDisabled"
category="secondary"
variant="danger"
@click="$emit('delete', mapTagsToBeDleeted(selectedItems))"
>
{{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
</gl-button>
</div>
<tags-list-row
v-for="(tag, index) in tags"
:key="tag.path"
:tag="tag"
:first="index === 0"
:selected="selectedItems[tag.name]"
: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>
</div>
</template>
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getContainerRepositoryDetails(
$id: ID!
$first: Int
$last: Int
$after: String
$before: String
) {
query getContainerRepositoryDetails($id: ID!) {
containerRepository(id: $id) {
id
name
......@@ -19,22 +11,6 @@ query getContainerRepositoryDetails(
tagsCount
expirationPolicyStartedAt
expirationPolicyCleanupStatus
tags(after: $after, before: $before, first: $first, last: $last) {
nodes {
digest
location
path
name
revision
shortRevision
createdAt
totalSize
canDelete
}
pageInfo {
...PageInfo
}
}
project {
visibility
containerExpirationPolicy {
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
query getContainerRepositoryDetails(
$id: ID!
$first: Int
$last: Int
$after: String
$before: String
) {
containerRepository(id: $id) {
id
tags(after: $after, before: $before, first: $first, last: $last) {
nodes {
digest
location
path
name
revision
shortRevision
createdAt
totalSize
canDelete
}
pageInfo {
...PageInfo
}
}
}
}
<script>
import { GlKeysetPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import createFlash from '~/flash';
import axios from '~/lib/utils/axios_utils';
......@@ -21,7 +21,6 @@ import {
ALERT_SUCCESS_TAGS,
ALERT_DANGER_TAGS,
ALERT_DANGER_IMAGE,
GRAPHQL_PAGE_SIZE,
FETCH_IMAGES_LIST_ERROR_MESSAGE,
UNFINISHED_STATUS,
MISSING_OR_DELETED_IMAGE_BREADCRUMB,
......@@ -36,7 +35,6 @@ export default {
DeleteAlert,
PartialCleanupAlert,
DetailsHeader,
GlKeysetPagination,
DeleteModal,
TagsList,
TagsLoader,
......@@ -58,8 +56,7 @@ export default {
update(data) {
return data.containerRepository;
},
result({ data }) {
this.tagsPageInfo = data.containerRepository?.tags?.pageInfo;
result() {
this.updateBreadcrumb();
},
error() {
......@@ -70,7 +67,6 @@ export default {
data() {
return {
image: {},
tagsPageInfo: {},
itemsToBeDeleted: [],
isMobile: false,
mutationLoading: false,
......@@ -83,15 +79,11 @@ export default {
queryVariables() {
return {
id: joinPaths(this.config.gidPrefix, `${this.$route.params.id}`),
first: GRAPHQL_PAGE_SIZE,
};
},
isLoading() {
return this.$apollo.queries.image.loading || this.mutationLoading;
},
tags() {
return this.image?.tags?.nodes || [];
},
showPartialCleanupWarning() {
return (
this.config.showUnfinishedTagCleanupCallout &&
......@@ -105,12 +97,6 @@ export default {
this.itemsToBeDeleted?.length > 1 ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
showPagination() {
return this.tagsPageInfo.hasPreviousPage || this.tagsPageInfo.hasNextPage;
},
hasNoTags() {
return this.tags.length === 0;
},
pageActionsAreDisabled() {
return Boolean(this.image?.status);
},
......@@ -124,7 +110,7 @@ export default {
},
deleteTags(toBeDeleted) {
this.deleteImageAlert = false;
this.itemsToBeDeleted = this.tags.filter((tag) => toBeDeleted[tag.name]);
this.itemsToBeDeleted = toBeDeleted;
this.track('click_button');
this.$refs.deleteModal.show();
},
......@@ -170,33 +156,6 @@ export default {
handleResize() {
this.isMobile = GlBreakpointInstance.getBreakpointSize() === 'xs';
},
fetchNextPage() {
if (this.tagsPageInfo?.hasNextPage) {
this.$apollo.queries.image.fetchMore({
variables: {
after: this.tagsPageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
fetchPreviousPage() {
if (this.tagsPageInfo?.hasPreviousPage) {
this.$apollo.queries.image.fetchMore({
variables: {
first: null,
before: this.tagsPageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
});
}
},
dismissPartialCleanupWarning() {
this.hidePartialCleanupWarning = true;
axios.post(this.config.userCalloutsPath, {
......@@ -246,27 +205,14 @@ export default {
/>
<tags-loader v-if="isLoading" />
<template v-else>
<empty-state v-if="hasNoTags" :no-containers-image="config.noContainersImage" />
<template v-else>
<tags-list
:tags="tags"
:is-mobile="isMobile"
:disabled="pageActionsAreDisabled"
@delete="deleteTags"
/>
<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>
<tags-list
v-else
:id="$route.params.id"
:is-image-loading="isLoading"
:is-mobile="isMobile"
:disabled="pageActionsAreDisabled"
@delete="deleteTags"
/>
<delete-image
:id="image.id"
......
---
title: 'Container Registry Details: split details from tags call'
merge_request: 59969
author:
type: changed
......@@ -161,6 +161,20 @@ export const tagsMock = [
},
];
export const imageTagsMock = (nodes = tagsMock) => ({
data: {
containerRepository: {
id: containerRepositoryMock.id,
tags: {
nodes,
pageInfo: { ...tagsPageInfo },
__typename: 'ContainerRepositoryTagConnection',
},
__typename: 'ContainerRepositoryDetails',
},
},
});
export const graphQLImageDetailsMock = (override) => ({
data: {
containerRepository: {
......
......@@ -28,12 +28,10 @@ import Tracking from '~/tracking';
import {
graphQLImageDetailsMock,
graphQLImageDetailsEmptyTagsMock,
graphQLDeleteImageRepositoryTagsMock,
containerRepositoryMock,
graphQLEmptyImageDetailsMock,
tagsMock,
tagsPageInfo,
} from '../mock_data';
import { DeleteModal } from '../stubs';
......@@ -72,12 +70,6 @@ describe('Details Page', () => {
await wrapper.vm.$nextTick();
};
const tagsArrayToSelectedTags = (tags) =>
tags.reduce((acc, c) => {
acc[c.name] = true;
return acc;
}, {});
const mountComponent = ({
resolver = jest.fn().mockResolvedValue(graphQLImageDetailsMock()),
mutationResolver = jest.fn().mockResolvedValue(graphQLDeleteImageRepositoryTagsMock),
......@@ -138,12 +130,6 @@ describe('Details Page', () => {
expect(findTagsList().exists()).toBe(false);
});
it('does not show pagination', () => {
mountComponent();
expect(findPagination().exists()).toBe(false);
});
});
describe('when the image does not exist', () => {
......@@ -167,34 +153,6 @@ describe('Details Page', () => {
});
});
describe('when the list of tags is empty', () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock);
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(findTagsList().exists()).toBe(false);
});
});
describe('list', () => {
it('exists', async () => {
mountComponent();
......@@ -211,7 +169,6 @@ describe('Details Page', () => {
expect(findTagsList().props()).toMatchObject({
isMobile: false,
tags: cleanTags,
});
});
......@@ -224,7 +181,7 @@ describe('Details Page', () => {
await waitForApolloRequestRender();
[tagToBeDeleted] = cleanTags;
findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true });
findTagsList().vm.$emit('delete', [tagToBeDeleted]);
});
it('open the modal', async () => {
......@@ -244,7 +201,7 @@ describe('Details Page', () => {
await waitForApolloRequestRender();
findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(cleanTags));
findTagsList().vm.$emit('delete', cleanTags);
});
it('open the modal', () => {
......@@ -260,61 +217,6 @@ describe('Details Page', () => {
});
});
describe('pagination', () => {
it('exists', async () => {
mountComponent();
await waitForApolloRequestRender();
expect(findPagination().exists()).toBe(true);
});
it('is hidden when there are no more pages', async () => {
mountComponent({ resolver: jest.fn().mockResolvedValue(graphQLImageDetailsEmptyTagsMock) });
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(graphQLImageDetailsMock());
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(graphQLImageDetailsMock());
mountComponent({ resolver });
await waitForApolloRequestRender();
findPagination().vm.$emit('prev');
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ first: null, before: tagsPageInfo.startCursor }),
);
});
});
describe('modal', () => {
it('exists', async () => {
mountComponent();
......@@ -349,7 +251,7 @@ describe('Details Page', () => {
});
describe('when one item is selected to be deleted', () => {
it('calls apollo mutation with the right parameters', async () => {
findTagsList().vm.$emit('delete', { [cleanTags[0].name]: true });
findTagsList().vm.$emit('delete', [cleanTags[0]]);
await wrapper.vm.$nextTick();
......@@ -363,7 +265,7 @@ describe('Details Page', () => {
describe('when more than one item is selected to be deleted', () => {
it('calls apollo mutation with the right parameters', async () => {
findTagsList().vm.$emit('delete', { ...tagsArrayToSelectedTags(tagsMock) });
findTagsList().vm.$emit('delete', tagsMock);
await wrapper.vm.$nextTick();
......
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