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__(
'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
export const DETAILS_PAGE_TITLE = s__('ContainerRegistry|%{imageName} tags');
......
......@@ -2,53 +2,52 @@
import { mapState, mapActions } from 'vuex';
import {
GlEmptyState,
GlPagination,
GlTooltipDirective,
GlDeprecatedButton,
GlIcon,
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
GlSearchBoxByClick,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue';
import ProjectPolicyAlert from '../components/project_policy_alert.vue';
import QuickstartDropdown from '../components/quickstart_dropdown.vue';
import ImageList from '../components/image_list.vue';
import {
DELETE_IMAGE_SUCCESS_MESSAGE,
DELETE_IMAGE_ERROR_MESSAGE,
ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT,
LIST_DELETE_BUTTON_DISABLED,
REMOVE_REPOSITORY_LABEL,
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';
export default {
name: 'RegistryListApp',
components: {
GlEmptyState,
GlPagination,
ProjectEmptyState,
GroupEmptyState,
ProjectPolicyAlert,
ClipboardButton,
QuickstartDropdown,
GlDeprecatedButton,
GlIcon,
ImageList,
GlModal,
GlSprintf,
GlLink,
GlAlert,
GlSkeletonLoader,
GlSearchBoxByClick,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -60,20 +59,23 @@ export default {
height: 40,
},
i18n: {
containerRegistryTitle: CONTAINER_REGISTRY_TITLE,
connectionErrorTitle: CONNECTION_ERROR_TITLE,
connectionErrorMessage: CONNECTION_ERROR_MESSAGE,
introText: LIST_INTRO_TEXT,
deleteButtonDisabled: LIST_DELETE_BUTTON_DISABLED,
removeRepositoryLabel: REMOVE_REPOSITORY_LABEL,
removeRepositoryModalText: REMOVE_REPOSITORY_MODAL_TEXT,
rowScheduledForDeletion: ROW_SCHEDULED_FOR_DELETION,
asyncDeleteErrorMessage: ASYNC_DELETE_IMAGE_ERROR_MESSAGE,
CONTAINER_REGISTRY_TITLE,
CONNECTION_ERROR_TITLE,
CONNECTION_ERROR_MESSAGE,
LIST_INTRO_TEXT,
REMOVE_REPOSITORY_MODAL_TEXT,
REMOVE_REPOSITORY_LABEL,
SEARCH_PLACEHOLDER_TEXT,
IMAGE_REPOSITORY_LIST_LABEL,
EMPTY_RESULT_TITLE,
EMPTY_RESULT_MESSAGE,
},
data() {
return {
itemToDelete: {},
deleteAlertType: null,
search: null,
isEmpty: false,
};
},
computed: {
......@@ -83,14 +85,6 @@ export default {
label: 'registry_repository_delete',
};
},
currentPage: {
get() {
return this.pagination.page;
},
set(page) {
this.requestImagesList({ page });
},
},
showQuickStartDropdown() {
return Boolean(!this.isLoading && !this.config?.isGroupPage && this.images?.length);
},
......@@ -110,8 +104,11 @@ export default {
...mapActions(['requestImagesList', 'requestDeleteImage']),
loadImageList(fromName) {
if (!fromName || !this.images?.length) {
this.requestImagesList();
return this.requestImagesList().then(() => {
this.isEmpty = this.images.length === 0;
});
}
return Promise.resolve();
},
deleteImage(item) {
this.track('click_button');
......@@ -128,10 +125,6 @@ export default {
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() {
this.deleteAlertType = null;
this.itemToDelete = {};
......@@ -160,12 +153,12 @@ export default {
<gl-empty-state
v-if="config.characterError"
:title="$options.i18n.connectionErrorTitle"
:title="$options.i18n.CONNECTION_ERROR_TITLE"
:svg-path="config.containersErrorImage"
>
<template #description>
<p>
<gl-sprintf :message="$options.i18n.connectionErrorMessage">
<gl-sprintf :message="$options.i18n.CONNECTION_ERROR_MESSAGE">
<template #docLink="{content}">
<gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
{{ content }}
......@@ -179,11 +172,11 @@ export default {
<template v-else>
<div>
<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" />
</div>
<p>
<gl-sprintf :message="$options.i18n.introText">
<gl-sprintf :message="$options.i18n.LIST_INTRO_TEXT">
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">
{{ content }}
......@@ -207,73 +200,40 @@ export default {
</gl-skeleton-loader>
</div>
<template v-else>
<div v-if="images.length" ref="imagesList" class="d-flex flex-column">
<div
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
v-gl-tooltip="{
placement: 'left',
disabled: !listItem.deleting,
title: $options.i18n.rowScheduledForDeletion,
}"
>
<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>
<template v-if="!isEmpty">
<div class="gl-display-flex gl-p-1" data-testid="listHeader">
<div class="gl-flex-fill-1">
<h5>{{ $options.i18n.IMAGE_REPOSITORY_LIST_LABEL }}</h5>
</div>
<div>
<gl-search-box-by-click
v-model="search"
:placeholder="$options.i18n.SEARCH_PLACEHOLDER_TEXT"
@submit="requestImagesList({ name: $event })"
/>
</div>
</div>
<gl-pagination
v-model="currentPage"
:per-page="pagination.perPage"
:total-items="pagination.total"
align="center"
class="w-100 mt-2"
<image-list
v-if="images.length"
:images="images"
:pagination="pagination"
@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>
<project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else />
......@@ -287,9 +247,9 @@ export default {
@ok="handleDeleteImage"
@cancel="track('cancel_delete')"
>
<template #modal-title>{{ $options.i18n.removeRepositoryLabel }}</template>
<template #modal-title>{{ $options.i18n.REMOVE_REPOSITORY_LABEL }}</template>
<p>
<gl-sprintf :message="$options.i18n.removeRepositoryModalText">
<gl-sprintf :message="$options.i18n.REMOVE_REPOSITORY_MODAL_TEXT">
<template #title>
<b>{{ itemToDelete.path }}</b>
</template>
......
......@@ -23,12 +23,15 @@ export const receiveTagsListSuccess = ({ commit }, { data, 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);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
.get(state.config.endpoint, { params: { page, per_page: perPage } })
.get(state.config.endpoint, { params: { page, per_page: perPage, name } })
.then(({ 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 ""
msgid "ContainerRegistry|Expiration schedule:"
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."
msgstr ""
msgid "ContainerRegistry|Image ID"
msgstr ""
msgid "ContainerRegistry|Image Repositories"
msgstr ""
msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr ""
......@@ -5772,6 +5778,9 @@ msgstr ""
msgid "ContainerRegistry|Something went wrong while updating the expiration policy."
msgstr ""
msgid "ContainerRegistry|Sorry, your filter produced no results."
msgstr ""
msgid "ContainerRegistry|Tag"
msgstr ""
......@@ -5823,6 +5832,9 @@ msgstr ""
msgid "ContainerRegistry|This image repository is scheduled for deletion"
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}"
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 = {
],
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