Commit 3bc24bd0 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Kushal Pandya

Extract image list to own component

- new component
- wire component
- unit tests
parent f6c40a9e
<script>
import { GlPagination, GlTooltipDirective, GlDeprecatedButton, GlIcon } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import {
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
} from '../constants';
export default {
name: 'ImageList',
components: {
GlPagination,
ClipboardButton,
GlDeprecatedButton,
GlIcon,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
images: {
type: Array,
required: true,
},
pagination: {
type: Object,
required: true,
},
},
i18n: {
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
ROW_SCHEDULED_FOR_DELETION,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
},
computed: {
currentPage: {
get() {
return this.pagination.page;
},
set(page) {
this.$emit('pageChange', page);
},
},
},
methods: {
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params);
},
},
};
</script>
<template>
<div class="gl-display-flex gl-flex-direction-column">
<div
v-for="(listItem, index) in images"
:key="index"
v-gl-tooltip="{
placement: 'left',
disabled: !listItem.deleting,
title: $options.i18n.ROW_SCHEDULED_FOR_DELETION,
}"
data-testid="rowItem"
>
<div
class="gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 border-bottom"
:class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
>
<div class="gl-display-flex gl-align-items-center">
<router-link
data-testid="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
:disabled="listItem.deleting"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
<gl-icon
v-if="listItem.failedDelete"
v-gl-tooltip
:title="$options.i18n.ASYNC_DELETE_IMAGE_ERROR_MESSAGE"
name="warning"
class="text-warning align-middle"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="$options.i18n.LIST_DELETE_BUTTON_DISABLED"
>
<gl-deprecated-button
v-gl-tooltip
data-testid="deleteImageButton"
:disabled="!listItem.destroy_path || listItem.deleting"
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
class="btn-inverted"
variant="danger"
@click="$emit('delete', listItem)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</div>
</div>
</div>
<gl-pagination
v-model="currentPage"
:per-page="pagination.perPage"
:total-items="pagination.total"
align="center"
class="w-100 gl-mt-2"
/>
</div>
</template>
...@@ -37,6 +37,15 @@ export const DELETE_IMAGE_SUCCESS_MESSAGE = s__( ...@@ -37,6 +37,15 @@ export const DELETE_IMAGE_SUCCESS_MESSAGE = s__(
'ContainerRegistry|%{title} was successfully scheduled for deletion', 'ContainerRegistry|%{title} was successfully scheduled for deletion',
); );
export const IMAGE_REPOSITORY_LIST_LABEL = s__('ContainerRegistry|Image Repositories');
export const SEARCH_PLACEHOLDER_TEXT = s__('ContainerRegistry|Filter by name');
export const EMPTY_RESULT_TITLE = s__('ContainerRegistry|Sorry, your filter produced no results.');
export const EMPTY_RESULT_MESSAGE = s__(
'ContainerRegistry|To widen your search, change or remove the filters above.',
);
// Image details page // Image details page
export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags'); export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
......
...@@ -2,53 +2,52 @@ ...@@ -2,53 +2,52 @@
import { mapState, mapActions } from 'vuex'; import { mapState, mapActions } from 'vuex';
import { import {
GlEmptyState, GlEmptyState,
GlPagination,
GlTooltipDirective, GlTooltipDirective,
GlDeprecatedButton,
GlIcon,
GlModal, GlModal,
GlSprintf, GlSprintf,
GlLink, GlLink,
GlAlert, GlAlert,
GlSkeletonLoader, GlSkeletonLoader,
GlSearchBoxByClick,
} from '@gitlab/ui'; } from '@gitlab/ui';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue'; import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue'; import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue'; import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue'; import QuickstartDropdown from '../components/quickstart_dropdown.vue';
import ImageList from '../components/image_list.vue';
import { import {
DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE, CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE, CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE, CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT, LIST_INTRO_TEXT,
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
REMOVE_REPOSITORY_MODAL_TEXT, REMOVE_REPOSITORY_MODAL_TEXT,
ROW_SCHEDULED_FOR_DELETION, REMOVE_REPOSITORY_LABEL,
SEARCH_PLACEHOLDER_TEXT,
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
} from '../constants'; } from '../constants';
export default { export default {
name: 'RegistryListApp', name: 'RegistryListApp',
components: { components: {
GlEmptyState, GlEmptyState,
GlPagination,
ProjectEmptyState, ProjectEmptyState,
GroupEmptyState, GroupEmptyState,
ProjectPolicyAlert, ProjectPolicyAlert,
ClipboardButton,
QuickstartDropdown, QuickstartDropdown,
GlDeprecatedButton, ImageList,
GlIcon,
GlModal, GlModal,
GlSprintf, GlSprintf,
GlLink, GlLink,
GlAlert, GlAlert,
GlSkeletonLoader, GlSkeletonLoader,
GlSearchBoxByClick,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -60,20 +59,23 @@ export default { ...@@ -60,20 +59,23 @@ export default {
height: 40, height: 40,
}, },
i18n: { i18n: {
containerRegistryTitle: CONTAINER_REGISTRY_TITLE, CONTAINER_REGISTRY_TITLE,
connectionErrorTitle: CONNECTION_ERROR_TITLE, CONNECTION_ERROR_TITLE,
connectionErrorMessage: CONNECTION_ERROR_MESSAGE, CONNECTION_ERROR_MESSAGE,
introText: LIST_INTRO_TEXT, LIST_INTRO_TEXT,
deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED, REMOVE_REPOSITORY_MODAL_TEXT,
removeRepositoryLabel: REMOVE_REPOSITORY_LABEL, REMOVE_REPOSITORY_LABEL,
removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT, SEARCH_PLACEHOLDER_TEXT,
rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION, IMAGE_REPOSITORY_LIST_LABEL,
asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE, EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
}, },
data() { data() {
return { return {
itemToDelete: {}, itemToDelete: {},
deleteAlertType: null, deleteAlertType: null,
search: null,
isEmpty: false,
}; };
}, },
computed: { computed: {
...@@ -83,14 +85,6 @@ export default { ...@@ -83,14 +85,6 @@ export default {
label: 'registry_repository_delete', label: 'registry_repository_delete',
}; };
}, },
currentPage: {
get() {
return this.pagination.page;
},
set(page) {
this.requestImagesList({ page });
},
},
showQuickStartDropdown() { showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length); return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
}, },
...@@ -110,8 +104,11 @@ export default { ...@@ -110,8 +104,11 @@ export default {
...mapActions(['requestImagesList', 'requestDeleteImage']), ...mapActions(['requestImagesList', 'requestDeleteImage']),
loadImageList(fromName) { loadImageList(fromName) {
if (!fromName || !this.images?.length) { if (!fromName || !this.images?.length) {
this.requestImagesList(); return this.requestImagesList().then(() => {
this.isEmpty = this.images.length === 0;
});
} }
return Promise.resolve();
}, },
deleteImage(item) { deleteImage(item) {
this.track('click_button'); this.track('click_button');
...@@ -128,10 +125,6 @@ export default { ...@@ -128,10 +125,6 @@ export default {
this.deleteAlertType = 'danger'; this.deleteAlertType = 'danger';
}); });
}, },
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params);
},
dismissDeleteAlert() { dismissDeleteAlert() {
this.deleteAlertType = null; this.deleteAlertType = null;
this.itemToDelete = {}; this.itemToDelete = {};
...@@ -160,12 +153,12 @@ export default { ...@@ -160,12 +153,12 @@ export default {
<gl-empty-state <gl-empty-state
v-if="config.characterError" v-if="config.characterError"
:title="$options.i18n.connectionErrorTitle" :title="$options.i18n.CONNECTION_ERROR_TITLE"
:svg-path="config.containersErrorImage" :svg-path="config.containersErrorImage"
> >
<template #description> <template #description>
<p> <p>
<gl-sprintf :message="$options.i18n.connectionErrorMessage"> <gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
<template #docLink="{content}"> <template #docLink="{content}">
<gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank"> <gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
{{ content }} {{ content }}
...@@ -179,11 +172,11 @@ export default { ...@@ -179,11 +172,11 @@ export default {
<template v-else> <template v-else>
<div> <div>
<div class="d-flex justify-content-between align-items-center"> <div class="d-flex justify-content-between align-items-center">
<h4>{{ $options.i18n.containerRegistryTitle }}</h4> <h4>{{ $options.i18n.CONTAINER_REGISTRY_TITLE }}</h4>
<quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" /> <quickstart-dropdown v-if="showQuickStartDropdown" class="d-none d-sm-block" />
</div> </div>
<p> <p>
<gl-sprintf :message="$options.i18n.introText"> <gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
<template #docLink="{content}"> <template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank"> <gl-link :href="config.helpPagePath" target="_blank">
{{ content }} {{ content }}
...@@ -207,73 +200,40 @@ export default { ...@@ -207,73 +200,40 @@ export default {
</gl-skeleton-loader> </gl-skeleton-loader>
</div> </div>
<template v-else> <template v-else>
<div v-if="images.length" ref="imagesList" class="d-flex flex-column"> <template v-if="!isEmpty">
<div <div class="gl-display-flex gl-p-1" data-testid="listHeader">
v-for="(listItem, index) in images" <div class="gl-flex-fill-1">
:key="index" <h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
ref="rowItem" </div>
v-gl-tooltip="{ <div>
placement: 'left', <gl-search-box-by-click
disabled: !listItem.deleting, v-model="search"
title: $options.i18n.rowScheduledForDeletion, :placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
}" @submit="requestImagesList({ name: $event })"
> />
<div
class="d-flex justify-content-between align-items-center py-2 px-1 border-bottom"
:class="{ 'border-top': index === 0, 'disabled-content': listItem.deleting }"
>
<div class="d-felx align-items-center">
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:disabled="listItem.deleting"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
<gl-icon
v-if="listItem.failedDelete"
v-gl-tooltip
:title="$options.i18n.asyncDeleteErrorMessage"
name="warning"
class="text-warning align-middle"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="$options.i18n.deleteButtonDisabled"
>
<gl-deprecated-button
ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path || listItem.deleting"
:title="$options.i18n.removeRepositoryLabel"
:aria-label="$options.i18n.removeRepositoryLabel"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</div>
</div> </div>
</div> </div>
<gl-pagination
v-model="currentPage" <image-list
:per-page="pagination.perPage" v-if="images.length"
:total-items="pagination.total" :images="images"
align="center" :pagination="pagination"
class="w-100 mt-2" @pageChange="requestImagesList({ pagination: { page: $event }, name: search })"
@delete="deleteImage"
/> />
</div>
<gl-empty-state
v-else
:svg-path="config.noContainersImage"
data-testid="emptySearch"
:title="$options.i18n.EMPTY_RESULT_TITLE"
class="container-message"
>
<template #description>
{{ $options.i18n.EMPTY_RESULT_MESSAGE }}
</template>
</gl-empty-state>
</template>
<template v-else> <template v-else>
<project-empty-state v-if="!config.isGroupPage" /> <project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else /> <group-empty-state v-else />
...@@ -287,9 +247,9 @@ export default { ...@@ -287,9 +247,9 @@ export default {
@ok="handleDeleteImage" @ok="handleDeleteImage"
@cancel="track('cancel_delete')" @cancel="track('cancel_delete')"
> >
<template #modal-title>{{ $options.i18n.removeRepositoryLabel }}</template> <template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
<p> <p>
<gl-sprintf :message="$options.i18n.removeRepositoryModalText"> <gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
<template #title> <template #title>
<b>{{ itemToDelete.path }}</b> <b>{{ itemToDelete.path }}</b>
</template> </template>
......
...@@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => { ...@@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, headers }) => {
commit(types.SET_TAGS_PAGINATION, headers); commit(types.SET_TAGS_PAGINATION, headers);
}; };
export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) => { export const requestImagesList = (
{ commit, dispatch, state },
{ pagination = {}, name = null } = {},
) => {
commit(types.SET_MAIN_LOADING, true); commit(types.SET_MAIN_LOADING, true);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination; const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios return axios
.get(state.config.endpoint, { params: { page, per_page: perPage } }) .get(state.config.endpoint, { params: { page, per_page: perPage, name } })
.then(({ data, headers }) => { .then(({ data, headers }) => {
dispatch('receiveImagesListSuccess', { data, headers }); dispatch('receiveImagesListSuccess', { data, headers });
}) })
......
---
title: Add search bar to container registry image list
merge_request: 31322
author:
type: added
...@@ -5698,12 +5698,18 @@ msgstr "" ...@@ -5698,12 +5698,18 @@ msgstr ""
msgid "ContainerRegistry|Expiration schedule:" msgid "ContainerRegistry|Expiration schedule:"
msgstr "" msgstr ""
msgid "ContainerRegistry|Filter by name"
msgstr ""
msgid "ContainerRegistry|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 %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password." msgid "ContainerRegistry|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 %{twofaDocLinkStart}Two-Factor Authentication%{twofaDocLinkEnd} enabled, use a %{personalAccessTokensDocLinkStart}Personal Access Token%{personalAccessTokensDocLinkEnd} instead of a password."
msgstr "" msgstr ""
msgid "ContainerRegistry|Image ID" msgid "ContainerRegistry|Image ID"
msgstr "" msgstr ""
msgid "ContainerRegistry|Image Repositories"
msgstr ""
msgid "ContainerRegistry|Keep and protect the images that matter most." msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr "" msgstr ""
...@@ -5772,6 +5778,9 @@ msgstr "" ...@@ -5772,6 +5778,9 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the expiration policy." msgid "ContainerRegistry|Something went wrong while updating the expiration policy."
msgstr "" msgstr ""
msgid "ContainerRegistry|Sorry, your filter produced no results."
msgstr ""
msgid "ContainerRegistry|Tag" msgid "ContainerRegistry|Tag"
msgstr "" msgstr ""
...@@ -5823,6 +5832,9 @@ msgstr "" ...@@ -5823,6 +5832,9 @@ msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion" msgid "ContainerRegistry|This image repository is scheduled for deletion"
msgstr "" msgstr ""
msgid "ContainerRegistry|To widen your search, change or remove the filters above."
msgstr ""
msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}" msgid "ContainerRegistry|We are having trouble connecting to Docker, which could be due to an issue with your project name or path. %{docLinkStart}More Information%{docLinkEnd}"
msgstr "" msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import Component from '~/registry/explorer/components/image_list.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { RouterLink } from '../stubs';
import { imagesListResponse, imagePagination } from '../mock_data';
describe('Image List', () => {
let wrapper;
const firstElement = imagesListResponse.data[0];
const findDeleteBtn = () => wrapper.find('[data-testid="deleteImageButton"]');
const findRowItems = () => wrapper.findAll('[data-testid="rowItem"]');
const findDetailsLink = () => wrapper.find('[data-testid="detailsLink"]');
const findClipboardButton = () => wrapper.find(ClipboardButton);
const findPagination = () => wrapper.find(GlPagination);
const mountComponent = () => {
wrapper = shallowMount(Component, {
stubs: {
RouterLink,
},
propsData: {
images: imagesListResponse.data,
pagination: imagePagination,
},
});
};
beforeEach(() => {
mountComponent();
});
it('contains one list element for each image', () => {
expect(findRowItems().length).toBe(imagesListResponse.data.length);
});
it('contains a link to the details page', () => {
const link = findDetailsLink();
expect(link.html()).toContain(firstElement.path);
expect(link.props('to').name).toBe('details');
});
it('contains a clipboard button', () => {
const button = findClipboardButton();
expect(button.exists()).toBe(true);
expect(button.props('text')).toBe(firstElement.location);
expect(button.props('title')).toBe(firstElement.location);
});
it('should be possible to delete a repo', () => {
const deleteBtn = findDeleteBtn();
expect(deleteBtn.exists()).toBe(true);
});
describe('pagination', () => {
it('exists', () => {
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(imagePagination.perPage);
expect(pagination.props('totalItems')).toBe(imagePagination.total);
expect(pagination.props('value')).toBe(imagePagination.page);
});
it('emits a pageChange event when the page change', () => {
wrapper.setData({ currentPage: 2 });
expect(wrapper.emitted('pageChange')).toEqual([[2]]);
});
});
});
...@@ -87,3 +87,11 @@ export const tagsListResponse = { ...@@ -87,3 +87,11 @@ export const tagsListResponse = {
], ],
headers, headers,
}; };
export const imagePagination = {
perPage: 10,
page: 1,
total: 14,
totalPages: 2,
nextPage: 2,
};
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