Commit a2cdc9b3 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Jose Ivan Vargas

Add details row component

- new component
- unit tests
parent 18ec84cd
<script>
import { GlIcon } from '@gitlab/ui';
export default {
components: {
GlIcon,
},
props: {
icon: {
type: String,
required: true,
},
},
};
</script>
<template>
<div
class="gl-display-flex gl-align-items-center gl-font-monospace gl-font-sm gl-py-2 gl-word-break-all"
>
<gl-icon :name="icon" class="gl-mr-4" />
<span>
<slot></slot>
</span>
</div>
</template>
......@@ -4,13 +4,18 @@ import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import DeleteButton from '../delete_button.vue';
import ListItem from '../list_item.vue';
import DetailsRow from './details_row.vue';
import {
REMOVE_TAG_BUTTON_TITLE,
SHORT_REVISION_LABEL,
DIGEST_LABEL,
CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
} from '../../constants/index';
export default {
......@@ -21,6 +26,7 @@ export default {
ListItem,
ClipboardButton,
TimeAgoTooltip,
DetailsRow,
},
directives: {
GlTooltip: GlTooltipDirective,
......@@ -43,9 +49,12 @@ export default {
},
i18n: {
REMOVE_TAG_BUTTON_TITLE,
SHORT_REVISION_LABEL,
DIGEST_LABEL,
CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
},
computed: {
formattedSize() {
......@@ -57,6 +66,25 @@ export default {
mobileClasses() {
return this.isDesktop ? '' : 'mw-s';
},
shortDigest() {
// remove sha256: from the string, and show only the first 7 char
return this.tag.digest?.substring(7, 14);
},
publishedDate() {
return formatDate(this.tag.created_at, 'isoDate');
},
publishedTime() {
return formatDate(this.tag.created_at, 'hh:MM Z');
},
formattedRevision() {
// to be removed when API response is adjusted
// see https://gitlab.com/gitlab-org/gitlab/-/issues/225324
// eslint-disable-next-line @gitlab/require-i18n-strings
return `sha256:${this.tag.revision}`;
},
tagLocation() {
return this.tag.path?.replace(`:${this.tag.name}`, '');
},
},
};
</script>
......@@ -110,9 +138,9 @@ export default {
</span>
</template>
<template #right-secondary>
<span data-testid="short-revision">
<gl-sprintf :message="$options.i18n.SHORT_REVISION_LABEL">
<template #imageId>{{ tag.short_revision }}</template>
<span data-testid="digest">
<gl-sprintf :message="$options.i18n.DIGEST_LABEL">
<template #imageId>{{ shortDigest }}</template>
</gl-sprintf>
</span>
</template>
......@@ -126,5 +154,50 @@ export default {
@delete="$emit('delete')"
/>
</template>
<template #details_published>
<details-row icon="clock" data-testid="published-date-detail">
<gl-sprintf :message="$options.i18n.PUBLISHED_DETAILS_ROW_TEXT">
<template #repositoryPath>
<i>{{ tagLocation }}</i>
</template>
<template #time>
{{ publishedTime }}
</template>
<template #date>
{{ publishedDate }}
</template>
</gl-sprintf>
</details-row>
</template>
<template #details_manifest_digest>
<details-row icon="log" data-testid="manifest-detail">
<gl-sprintf :message="$options.i18n.MANIFEST_DETAILS_ROW_TEST">
<template #digest>
{{ tag.digest }}
</template>
</gl-sprintf>
<clipboard-button
v-if="tag.digest"
:title="tag.digest"
:text="tag.digest"
css-class="btn-default btn-transparent btn-clipboard gl-p-0"
/>
</details-row>
</template>
<template #details_configuration_digest>
<details-row icon="cloud-gear" data-testid="configuration-detail">
<gl-sprintf :message="$options.i18n.CONFIGURATION_DETAILS_ROW_TEST">
<template #digest>
{{ formattedRevision }}
</template>
</gl-sprintf>
<clipboard-button
v-if="formattedRevision"
:title="formattedRevision"
:text="formattedRevision"
css-class="btn-default btn-transparent btn-clipboard gl-p-0"
/>
</details-row>
</template>
</list-item>
</template>
......@@ -79,7 +79,7 @@ export default {
:selected="isDetailsShown"
icon="ellipsis_h"
size="small"
class="gl-ml-2"
class="gl-ml-2 gl-display-none gl-display-sm-block"
@click="toggleDetails"
/>
</div>
......
......@@ -16,8 +16,15 @@ export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
);
export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags');
export const SHORT_REVISION_LABEL = s__('ContainerRegistry|Image ID: %{imageId}');
export const DIGEST_LABEL = s__('ContainerRegistry|Digest: %{imageId}');
export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}');
export const PUBLISHED_DETAILS_ROW_TEXT = s__(
'ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}',
);
export const MANIFEST_DETAILS_ROW_TEST = s__('ContainerRegistry|Manifest digest: %{digest}');
export const CONFIGURATION_DETAILS_ROW_TEST = s__(
'ContainerRegistry|Configuration digest: %{digest}',
);
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected');
......
---
title: Add details rows to Container Registry Tags List
merge_request: 36036
author:
type: changed
......@@ -6207,6 +6207,9 @@ msgstr ""
msgid "ContainerRegistry|Cleanup policy:"
msgstr ""
msgid "ContainerRegistry|Configuration digest: %{digest}"
msgstr ""
msgid "ContainerRegistry|Container Registry"
msgstr ""
......@@ -6222,6 +6225,9 @@ msgstr ""
msgid "ContainerRegistry|Delete selected"
msgstr ""
msgid "ContainerRegistry|Digest: %{imageId}"
msgstr ""
msgid "ContainerRegistry|Docker connection error"
msgstr ""
......@@ -6246,9 +6252,6 @@ 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: %{imageId}"
msgstr ""
msgid "ContainerRegistry|Image Repositories"
msgstr ""
......@@ -6258,6 +6261,9 @@ msgstr ""
msgid "ContainerRegistry|Login"
msgstr ""
msgid "ContainerRegistry|Manifest digest: %{digest}"
msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
......@@ -6267,6 +6273,9 @@ msgstr ""
msgid "ContainerRegistry|Published %{timeInfo}"
msgstr ""
msgid "ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}"
msgstr ""
msgid "ContainerRegistry|Push an image"
msgstr ""
......
import { shallowMount } from '@vue/test-utils';
import { GlIcon } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/details_row.vue';
describe('DetailsRow', () => {
let wrapper;
const findIcon = () => wrapper.find(GlIcon);
const findDefaultSlot = () => wrapper.find('[data-testid="default-slot"]');
const mountComponent = () => {
wrapper = shallowMount(component, {
propsData: {
icon: 'clock',
},
slots: {
default: '<div data-testid="default-slot"></div>',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains an icon', () => {
mountComponent();
expect(findIcon().exists()).toBe(true);
});
it('icon has the correct props', () => {
mountComponent();
expect(findIcon().props()).toMatchObject({
name: 'clock',
});
});
it('has a default slot', () => {
mountComponent();
expect(findDefaultSlot().exists()).toBe(true);
});
});
......@@ -29,7 +29,7 @@ describe('EmptyTagsState component', () => {
it('contains gl-empty-state', () => {
mountComponent();
expect(findEmptyState().exist()).toBe(true);
expect(findEmptyState().exists()).toBe(true);
});
it('has the correct props', () => {
......
import { shallowMount } from '@vue/test-utils';
import { GlFormCheckbox, GlSprintf } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/tags_list_row.vue';
import ListItem from '~/registry/explorer/components/list_item.vue';
import DeleteButton from '~/registry/explorer/components/delete_button.vue';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import component from '~/registry/explorer/components/details_page/tags_list_row.vue';
import DeleteButton from '~/registry/explorer/components/delete_button.vue';
import DetailsRow from '~/registry/explorer/components/details_page/details_row.vue';
import {
REMOVE_TAG_BUTTON_TITLE,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
......@@ -13,6 +13,7 @@ import {
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { tagsListResponse } from '../../mock_data';
import { ListItem } from '../../stubs';
describe('tags list row', () => {
let wrapper;
......@@ -24,16 +25,21 @@ describe('tags list row', () => {
const findName = () => wrapper.find('[data-testid="name"]');
const findSize = () => wrapper.find('[data-testid="size"]');
const findTime = () => wrapper.find('[data-testid="time"]');
const findShortRevision = () => wrapper.find('[data-testid="short-revision"]');
const findShortRevision = () => wrapper.find('[data-testid="digest"]');
const findClipboardButton = () => wrapper.find(ClipboardButton);
const findDeleteButton = () => wrapper.find(DeleteButton);
const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip);
const findDetailsRows = () => wrapper.findAll(DetailsRow);
const findPublishedDateDetail = () => wrapper.find('[data-testid="published-date-detail"]');
const findManifestDetail = () => wrapper.find('[data-testid="manifest-detail"]');
const findConfigurationDetail = () => wrapper.find('[data-testid="configuration-detail"]');
const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMount(component, {
stubs: {
GlSprintf,
ListItem,
DetailsRow,
},
propsData,
directives: {
......@@ -114,6 +120,7 @@ describe('tags list row', () => {
it('is hidden if tag does not have a location', () => {
mountComponent({ ...defaultProps, tag: { ...tag, location: null } });
expect(findClipboardButton().exists()).toBe(false);
});
......@@ -136,21 +143,25 @@ describe('tags list row', () => {
it('contains the total_size and layers', () => {
mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers');
});
it('when total_size is missing', () => {
mountComponent();
expect(findSize().text()).toMatchInterpolatedText('10 layers');
});
it('when layers are missing', () => {
mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB');
});
it('when there is 1 layer', () => {
mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } });
expect(findSize().text()).toMatchInterpolatedText('1 layer');
});
});
......@@ -181,7 +192,7 @@ describe('tags list row', () => {
});
});
describe('shortRevision', () => {
describe('digest', () => {
it('exists', () => {
mountComponent();
......@@ -191,7 +202,7 @@ describe('tags list row', () => {
it('has the correct text', () => {
mountComponent();
expect(findShortRevision().text()).toMatchInterpolatedText('Image ID: b118ab5b0');
expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 1ab51d5');
});
});
......@@ -226,4 +237,39 @@ describe('tags list row', () => {
expect(wrapper.emitted('delete')).toEqual([[]]);
});
});
describe('details rows', () => {
beforeEach(() => {
mountComponent();
return wrapper.vm.$nextTick();
});
it('has 3 details rows', () => {
expect(findDetailsRows().length).toBe(3);
});
describe.each`
name | finderFunction | text | icon | clipboard
${'published date detail'} | ${findPublishedDateDetail} | ${'Published to the bar image repository at 10:23 GMT+0000 on 2020-06-29'} | ${'clock'} | ${false}
${'manifest detail'} | ${findManifestDetail} | ${'Manifest digest: sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c'} | ${'log'} | ${true}
${'configuration detail'} | ${findConfigurationDetail} | ${'Configuration digest: sha256:b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43'} | ${'cloud-gear'} | ${true}
`('$name details row', ({ finderFunction, text, icon, clipboard }) => {
it(`has ${text} as text`, () => {
expect(finderFunction().text()).toMatchInterpolatedText(text);
});
it(`has the ${icon} icon`, () => {
expect(finderFunction().props('icon')).toBe(icon);
});
it(`is ${clipboard} that clipboard button exist`, () => {
expect(
finderFunction()
.find(ClipboardButton)
.exists(),
).toBe(clipboard);
});
});
});
});
......@@ -70,9 +70,10 @@ export const tagsListResponse = {
size: 19,
layers: 10,
location: 'location',
path: 'bar',
created_at: '1505828744434',
path: 'bar:centos6',
created_at: '2020-06-29T10:23:51.766+00:00',
destroy_path: 'path',
digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c',
},
{
name: 'test-tag',
......@@ -80,9 +81,10 @@ export const tagsListResponse = {
short_revision: 'b969de599',
size: 19,
layers: 10,
path: 'foo',
path: 'foo:test-tag',
location: 'location-2',
created_at: '1505828744434',
created_at: '2020-06-29T10:23:51.766+00:00',
digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7736dfd5c',
},
],
headers,
......
import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
import RealListItem from '~/registry/explorer/components/list_item.vue';
export const GlModal = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
......@@ -29,3 +30,13 @@ export const GlSkeletonLoader = {
template: `<div><slot></slot></div>`,
props: ['width', 'height'],
};
export const ListItem = {
...RealListItem,
data() {
return {
detailsSlots: [],
isDetailsShown: true,
};
},
};
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