Commit d3256894 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '280847-reduce-lcp-in-container-registry-index-page' into 'master'

Defer tagsCount & add startup.js to container registry

See merge request gitlab-org/gitlab!50147
parents f20ee83e 836b50eb
......@@ -13,6 +13,11 @@ export default {
type: Array,
required: true,
},
metadataLoading: {
type: Boolean,
default: false,
required: false,
},
pageInfo: {
type: Object,
required: true,
......@@ -33,6 +38,7 @@ export default {
:key="index"
:item="listItem"
:first="index === 0"
:metadata-loading="metadataLoading"
@delete="$emit('delete', $event)"
/>
<div class="gl-display-flex gl-justify-content-center">
......
<script>
import { GlTooltipDirective, GlIcon, GlSprintf } from '@gitlab/ui';
import { GlTooltipDirective, GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
import { n__ } from '~/locale';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
......@@ -25,6 +25,7 @@ export default {
GlSprintf,
GlIcon,
ListItem,
GlSkeletonLoader,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -34,6 +35,11 @@ export default {
type: Object,
required: true,
},
metadataLoading: {
type: Boolean,
default: false,
required: false,
},
},
i18n: {
LIST_DELETE_BUTTON_DISABLED,
......@@ -107,7 +113,11 @@ export default {
/>
</template>
<template #left-secondary>
<span class="gl-display-flex gl-align-items-center" data-testid="tagsCount">
<span
v-if="!metadataLoading"
class="gl-display-flex gl-align-items-center"
data-testid="tags-count"
>
<gl-icon name="tag" class="gl-mr-2" />
<gl-sprintf :message="tagsCountText">
<template #count>
......@@ -115,6 +125,13 @@ export default {
</template>
</gl-sprintf>
</span>
<div v-else class="gl-w-full">
<gl-skeleton-loader :width="900" :height="16" preserve-aspect-ratio="xMinYMax meet">
<circle cx="6" cy="8" r="6" />
<rect x="16" y="4" width="100" height="8" rx="4" />
</gl-skeleton-loader>
</div>
</template>
<template #right-action>
<delete-button
......
fragment ContainerRepositoryFields on ContainerRepository {
id
name
path
status
location
canDelete
createdAt
tagsCount
expirationPolicyStartedAt
}
......@@ -8,6 +8,7 @@ export const apolloProvider = new VueApollo({
defaultClient: createDefaultClient(
{},
{
batchMax: 1,
assumeImmutableResults: true,
},
),
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/container_repository.fragment.graphql"
query getGroupContainerRepositories(
query getContainerRepositoriesDetails(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
$isGroupPage: Boolean!
) {
group(fullPath: $fullPath) {
containerRepositoriesCount
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
...ContainerRepositoryFields
id
tagsCount
}
pageInfo {
...PageInfo
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
id
tagsCount
}
}
}
......
#import "~/graphql_shared/fragments/pageInfo.fragment.graphql"
#import "../fragments/container_repository.fragment.graphql"
query getProjectContainerRepositories(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
) {
project(fullPath: $fullPath) {
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
nodes {
...ContainerRepositoryFields
}
pageInfo {
...PageInfo
}
}
}
}
......@@ -9,17 +9,13 @@ import {
GlSkeletonLoader,
GlSearchBoxByClick,
} from '@gitlab/ui';
import { get } from 'lodash';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import Tracking from '~/tracking';
import createFlash from '~/flash';
import ProjectEmptyState from '../components/list_page/project_empty_state.vue';
import GroupEmptyState from '../components/list_page/group_empty_state.vue';
import RegistryHeader from '../components/list_page/registry_header.vue';
import ImageList from '../components/list_page/image_list.vue';
import CliCommands from '../components/list_page/cli_commands.vue';
import getProjectContainerRepositoriesQuery from '../graphql/queries/get_project_container_repositories.query.graphql';
import getGroupContainerRepositoriesQuery from '../graphql/queries/get_group_container_repositories.query.graphql';
import getContainerRepositoriesDetails from '../graphql/queries/get_container_repositories_details.query.graphql';
import deleteContainerRepositoryMutation from '../graphql/mutations/delete_container_repository.mutation.graphql';
import {
......@@ -41,9 +37,22 @@ export default {
name: 'RegistryListPage',
components: {
GlEmptyState,
ProjectEmptyState,
GroupEmptyState,
ImageList,
ProjectEmptyState: () =>
import(
/* webpackChunkName: 'container_registry_components' */ '../components/list_page/project_empty_state.vue'
),
GroupEmptyState: () =>
import(
/* webpackChunkName: 'container_registry_components' */ '../components/list_page/group_empty_state.vue'
),
ImageList: () =>
import(
/* webpackChunkName: 'container_registry_components' */ '../components/list_page/image_list.vue'
),
CliCommands: () =>
import(
/* webpackChunkName: 'container_registry_components' */ '../components/list_page/cli_commands.vue'
),
GlModal,
GlSprintf,
GlLink,
......@@ -51,7 +60,6 @@ export default {
GlSkeletonLoader,
GlSearchBoxByClick,
RegistryHeader,
CliCommands,
},
inject: ['config'],
directives: {
......@@ -74,10 +82,8 @@ export default {
EMPTY_RESULT_MESSAGE,
},
apollo: {
images: {
query() {
return this.graphQlQuery;
},
baseImages: {
query: getContainerRepositoriesQuery,
variables() {
return this.queryVariables;
},
......@@ -92,10 +98,26 @@ export default {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
},
},
additionalDetails: {
skip() {
return !this.fetchAdditionalDetails;
},
query: getContainerRepositoriesDetails,
variables() {
return this.queryVariables;
},
update(data) {
return data[this.graphqlResource]?.containerRepositories.nodes;
},
error() {
createFlash({ message: FETCH_IMAGES_LIST_ERROR_MESSAGE });
},
},
},
data() {
return {
images: [],
baseImages: [],
additionalDetails: [],
pageInfo: {},
containerRepositoriesCount: 0,
itemToDelete: {},
......@@ -103,21 +125,24 @@ export default {
searchValue: null,
name: null,
mutationLoading: false,
fetchAdditionalDetails: false,
};
},
computed: {
images() {
return this.baseImages.map((image, index) => ({
...image,
...get(this.additionalDetails, index, {}),
}));
},
graphqlResource() {
return this.config.isGroupPage ? 'group' : 'project';
},
graphQlQuery() {
return this.config.isGroupPage
? getGroupContainerRepositoriesQuery
: getProjectContainerRepositoriesQuery;
},
queryVariables() {
return {
name: this.name,
fullPath: this.config.isGroupPage ? this.config.groupPath : this.config.projectPath,
isGroupPage: this.config.isGroupPage,
first: GRAPHQL_PAGE_SIZE,
};
},
......@@ -127,7 +152,7 @@ export default {
};
},
isLoading() {
return this.$apollo.queries.images.loading || this.mutationLoading;
return this.$apollo.queries.baseImages.loading || this.mutationLoading;
},
showCommands() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
......@@ -141,6 +166,13 @@ export default {
: DELETE_IMAGE_ERROR_MESSAGE;
},
},
mounted() {
// If the two graphql calls - which are not batched - resolve togheter we will have a race
// condition when apollo sets the cache, with this we give the 'base' call an headstart
setTimeout(() => {
this.fetchAdditionalDetails = true;
}, 200);
},
methods: {
deleteImage(item) {
this.track('click_button');
......@@ -175,30 +207,46 @@ export default {
this.deleteAlertType = null;
this.itemToDelete = {};
},
fetchNextPage() {
updateQuery(_, { fetchMoreResult }) {
return fetchMoreResult;
},
async fetchNextPage() {
if (this.pageInfo?.hasNextPage) {
this.$apollo.queries.images.fetchMore({
variables: {
after: this.pageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
const variables = {
after: this.pageInfo?.endCursor,
first: GRAPHQL_PAGE_SIZE,
};
this.$apollo.queries.baseImages.fetchMore({
variables,
updateQuery: this.updateQuery,
});
await this.$nextTick();
this.$apollo.queries.additionalDetails.fetchMore({
variables,
updateQuery: this.updateQuery,
});
}
},
fetchPreviousPage() {
async fetchPreviousPage() {
if (this.pageInfo?.hasPreviousPage) {
this.$apollo.queries.images.fetchMore({
variables: {
first: null,
before: this.pageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
},
updateQuery(previousResult, { fetchMoreResult }) {
return fetchMoreResult;
},
const variables = {
first: null,
before: this.pageInfo?.startCursor,
last: GRAPHQL_PAGE_SIZE,
};
this.$apollo.queries.baseImages.fetchMore({
variables,
updateQuery: this.updateQuery,
});
await this.$nextTick();
this.$apollo.queries.additionalDetails.fetchMore({
variables,
updateQuery: this.updateQuery,
});
}
},
......@@ -286,6 +334,7 @@ export default {
<image-list
v-if="images.length"
:images="images"
:metadata-loading="$apollo.queries.additionalDetails.loading"
:page-info="pageInfo"
@delete="deleteImage"
@prev-page="fetchPreviousPage"
......
query getProjectContainerRepositories(
$fullPath: ID!
$name: String
$first: Int
$last: Int
$after: String
$before: String
$isGroupPage: Boolean!
) {
project(fullPath: $fullPath) @skip(if: $isGroupPage) {
__typename
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
__typename
nodes {
id
name
path
status
location
canDelete
createdAt
expirationPolicyStartedAt
__typename
}
pageInfo {
__typename
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
group(fullPath: $fullPath) @include(if: $isGroupPage) {
__typename
containerRepositoriesCount
containerRepositories(name: $name, after: $after, before: $before, first: $first, last: $last) {
__typename
nodes {
id
name
path
status
location
canDelete
createdAt
expirationPolicyStartedAt
__typename
}
pageInfo {
__typename
hasNextPage
hasPreviousPage
startCursor
endCursor
}
}
}
}
- page_title _("Container Registry")
- @content_class = "limit-container-width" unless fluid_layout
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @group.full_path, first: 10, name: nil, isGroupPage: true} )
%section
#js-container-registry{ data: { endpoint: group_container_registries_path(@group),
......
- page_title _("Container Registry")
- @content_class = "limit-container-width" unless fluid_layout
- add_page_startup_graphql_call('container_registry/get_container_repositories', { fullPath: @project.full_path, first: 10, name: nil, isGroupPage: false} )
%section
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
......
---
title: Defer tagsCount & add startup.js to container registry
merge_request: 50147
author:
type: changed
import { shallowMount } from '@vue/test-utils';
import { GlIcon, GlSprintf } from '@gitlab/ui';
import { GlIcon, GlSprintf, GlSkeletonLoader } from '@gitlab/ui';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { getIdFromGraphQLId } from '~/graphql_shared/utils';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
......@@ -23,10 +23,11 @@ describe('Image List Row', () => {
const [item] = imagesListResponse;
const findDetailsLink = () => wrapper.find('[data-testid="details-link"]');
const findTagsCount = () => wrapper.find('[data-testid="tagsCount"]');
const findTagsCount = () => wrapper.find('[data-testid="tags-count"]');
const findDeleteBtn = () => wrapper.find(DeleteButton);
const findClipboardButton = () => wrapper.find(ClipboardButton);
const findWarningIcon = () => wrapper.find('[data-testid="warning-icon"]');
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const mountComponent = (props) => {
wrapper = shallowMount(Component, {
......@@ -164,6 +165,20 @@ describe('Image List Row', () => {
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 } });
......
......@@ -11,11 +11,12 @@ describe('Image List', () => {
const findRow = () => wrapper.findAll(ImageListRow);
const findPagination = () => wrapper.find(GlKeysetPagination);
const mountComponent = (pageInfo = defaultPageInfo) => {
const mountComponent = (props) => {
wrapper = shallowMount(Component, {
propsData: {
images: imagesListResponse,
pageInfo,
pageInfo: defaultPageInfo,
...props,
},
});
};
......@@ -38,6 +39,11 @@ describe('Image List', () => {
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', () => {
......@@ -55,7 +61,7 @@ describe('Image List', () => {
`(
'when hasNextPage is $hasNextPage and hasPreviousPage is $hasPreviousPage: is $isVisible that the component is visible',
({ hasNextPage, hasPreviousPage, isVisible }) => {
mountComponent({ hasNextPage, hasPreviousPage });
mountComponent({ pageInfo: { ...defaultPageInfo, hasNextPage, hasPreviousPage } });
expect(findPagination().exists()).toBe(isVisible);
expect(findPagination().props('hasPreviousPage')).toBe(hasPreviousPage);
......@@ -64,7 +70,7 @@ describe('Image List', () => {
);
it('emits "prev-page" when the user clicks the back page button', () => {
mountComponent({ hasPreviousPage: true });
mountComponent();
findPagination().vm.$emit('prev');
......@@ -72,7 +78,7 @@ describe('Image List', () => {
});
it('emits "next-page" when the user clicks the forward page button', () => {
mountComponent({ hasNextPage: true });
mountComponent();
findPagination().vm.$emit('next');
......
......@@ -8,7 +8,6 @@ export const imagesListResponse = [
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-12009',
canDelete: true,
createdAt: '2020-11-03T13:29:21Z',
tagsCount: 18,
expirationPolicyStartedAt: null,
},
{
......@@ -20,7 +19,6 @@ export const imagesListResponse = [
location: '0.0.0.0:5000/gitlab-org/gitlab-test/rails-20572',
canDelete: true,
createdAt: '2020-09-21T06:57:43Z',
tagsCount: 1,
expirationPolicyStartedAt: null,
},
];
......@@ -209,3 +207,26 @@ export const dockerCommands = {
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',
},
},
};
......@@ -3,6 +3,7 @@ import VueApollo from 'vue-apollo';
import { GlSkeletonLoader, GlSprintf, GlAlert, GlSearchBoxByClick } from '@gitlab/ui';
import createMockApollo from 'jest/helpers/mock_apollo_helper';
import waitForPromises from 'helpers/wait_for_promises';
import getContainerRepositoriesQuery from 'shared_queries/container_registry/get_container_repositories.query.graphql';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
import CliCommands from '~/registry/explorer/components/list_page/cli_commands.vue';
......@@ -19,8 +20,7 @@ import {
SEARCH_PLACEHOLDER_TEXT,
} from '~/registry/explorer/constants';
import getProjectContainerRepositoriesQuery from '~/registry/explorer/graphql/queries/get_project_container_repositories.query.graphql';
import getGroupContainerRepositoriesQuery from '~/registry/explorer/graphql/queries/get_group_container_repositories.query.graphql';
import getContainerRepositoriesDetails from '~/registry/explorer/graphql/queries/get_container_repositories_details.query.graphql';
import deleteContainerRepositoryMutation from '~/registry/explorer/graphql/mutations/delete_container_repository.mutation.graphql';
import {
......@@ -31,6 +31,8 @@ import {
graphQLEmptyImageListMock,
graphQLEmptyGroupImageListMock,
pageInfo,
graphQLProjectImageRepositoriesDetailsMock,
dockerCommands,
} from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
import { $toast } from '../../shared/mocks';
......@@ -58,6 +60,7 @@ describe('List Page', () => {
const findEmptySearchMessage = () => wrapper.find('[data-testid="emptySearch"]');
const waitForApolloRequestRender = async () => {
jest.runOnlyPendingTimers();
await waitForPromises();
await wrapper.vm.$nextTick();
};
......@@ -65,15 +68,15 @@ describe('List Page', () => {
const mountComponent = ({
mocks,
resolver = jest.fn().mockResolvedValue(graphQLImageListMock),
groupResolver = jest.fn().mockResolvedValue(graphQLImageListMock),
detailsResolver = jest.fn().mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock),
mutationResolver = jest.fn().mockResolvedValue(graphQLImageDeleteMock),
config = {},
config = { isGroupPage: false },
} = {}) => {
localVue.use(VueApollo);
const requestHandlers = [
[getProjectContainerRepositoriesQuery, resolver],
[getGroupContainerRepositoriesQuery, groupResolver],
[getContainerRepositoriesQuery, resolver],
[getContainerRepositoriesDetails, detailsResolver],
[deleteContainerRepositoryMutation, mutationResolver],
];
......@@ -99,6 +102,7 @@ describe('List Page', () => {
provide() {
return {
config,
...dockerCommands,
};
},
});
......@@ -125,6 +129,7 @@ describe('List Page', () => {
characterError: true,
containersErrorImage: 'foo',
helpPagePath: 'bar',
isGroupPage: false,
};
it('should show an empty state', () => {
......@@ -199,15 +204,16 @@ describe('List Page', () => {
expect(findProjectEmptyState().exists()).toBe(true);
});
});
describe('group page', () => {
const groupResolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
const resolver = jest.fn().mockResolvedValue(graphQLEmptyGroupImageListMock);
const config = {
isGroupPage: true,
};
it('group empty state is visible', async () => {
mountComponent({ groupResolver, config });
mountComponent({ resolver, config });
await waitForApolloRequestRender();
......@@ -215,7 +221,7 @@ describe('List Page', () => {
});
it('cli commands is not visible', async () => {
mountComponent({ groupResolver, config });
mountComponent({ resolver, config });
await waitForApolloRequestRender();
......@@ -223,7 +229,7 @@ describe('List Page', () => {
});
it('list header is not visible', async () => {
mountComponent({ groupResolver, config });
mountComponent({ resolver, config });
await waitForApolloRequestRender();
......@@ -260,6 +266,39 @@ describe('List Page', () => {
expect(header.text()).toBe(IMAGE_REPOSITORY_LIST_LABEL);
});
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 deleteImage = async () => {
await wrapper.vm.$nextTick();
......@@ -357,9 +396,15 @@ describe('List Page', () => {
it('when search result is empty displays an empty search message', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
const detailsResolver = jest
.fn()
.mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
mountComponent({ resolver, detailsResolver });
await waitForApolloRequestRender();
resolver.mockResolvedValue(graphQLEmptyImageListMock);
detailsResolver.mockResolvedValue(graphQLEmptyImageListMock);
await doSearch();
......@@ -370,28 +415,42 @@ describe('List Page', () => {
describe('pagination', () => {
it('prev-page event triggers a fetchMore request', async () => {
const resolver = jest.fn().mockResolvedValue(graphQLImageListMock);
mountComponent({ resolver });
const detailsResolver = jest
.fn()
.mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
mountComponent({ resolver, detailsResolver });
await waitForApolloRequestRender();
findImageList().vm.$emit('prev-page');
await wrapper.vm.$nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ first: null, before: pageInfo.startCursor }),
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);
mountComponent({ resolver });
const detailsResolver = jest
.fn()
.mockResolvedValue(graphQLProjectImageRepositoriesDetailsMock);
mountComponent({ resolver, detailsResolver });
await waitForApolloRequestRender();
findImageList().vm.$emit('next-page');
await wrapper.vm.$nextTick();
expect(resolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }),
);
expect(detailsResolver).toHaveBeenCalledWith(
expect.objectContaining({ after: pageInfo.endCursor }),
);
});
});
});
......
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