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'; ...@@ -4,13 +4,18 @@ import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue'; import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue'; import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.vue';
import { formatDate } from '~/lib/utils/datetime_utility';
import DeleteButton from '../delete_button.vue'; import DeleteButton from '../delete_button.vue';
import ListItem from '../list_item.vue'; import ListItem from '../list_item.vue';
import DetailsRow from './details_row.vue';
import { import {
REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_TITLE,
SHORT_REVISION_LABEL, DIGEST_LABEL,
CREATED_AT_LABEL, CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
} from '../../constants/index'; } from '../../constants/index';
export default { export default {
...@@ -21,6 +26,7 @@ export default { ...@@ -21,6 +26,7 @@ export default {
ListItem, ListItem,
ClipboardButton, ClipboardButton,
TimeAgoTooltip, TimeAgoTooltip,
DetailsRow,
}, },
directives: { directives: {
GlTooltip: GlTooltipDirective, GlTooltip: GlTooltipDirective,
...@@ -43,9 +49,12 @@ export default { ...@@ -43,9 +49,12 @@ export default {
}, },
i18n: { i18n: {
REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_TITLE,
SHORT_REVISION_LABEL, DIGEST_LABEL,
CREATED_AT_LABEL, CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
PUBLISHED_DETAILS_ROW_TEXT,
MANIFEST_DETAILS_ROW_TEST,
CONFIGURATION_DETAILS_ROW_TEST,
}, },
computed: { computed: {
formattedSize() { formattedSize() {
...@@ -57,6 +66,25 @@ export default { ...@@ -57,6 +66,25 @@ export default {
mobileClasses() { mobileClasses() {
return this.isDesktop ? '' : 'mw-s'; 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> </script>
...@@ -110,9 +138,9 @@ export default { ...@@ -110,9 +138,9 @@ export default {
</span> </span>
</template> </template>
<template #right-secondary> <template #right-secondary>
<span data-testid="short-revision"> <span data-testid="digest">
<gl-sprintf :message="$options.i18n.SHORT_REVISION_LABEL"> <gl-sprintf :message="$options.i18n.DIGEST_LABEL">
<template #imageId>{{ tag.short_revision }}</template> <template #imageId>{{ shortDigest }}</template>
</gl-sprintf> </gl-sprintf>
</span> </span>
</template> </template>
...@@ -126,5 +154,50 @@ export default { ...@@ -126,5 +154,50 @@ export default {
@delete="$emit('delete')" @delete="$emit('delete')"
/> />
</template> </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> </list-item>
</template> </template>
...@@ -79,7 +79,7 @@ export default { ...@@ -79,7 +79,7 @@ export default {
:selected="isDetailsShown" :selected="isDetailsShown"
icon="ellipsis_h" icon="ellipsis_h"
size="small" size="small"
class="gl-ml-2" class="gl-ml-2 gl-display-none gl-display-sm-block"
@click="toggleDetails" @click="toggleDetails"
/> />
</div> </div>
......
...@@ -16,8 +16,15 @@ export const DELETE_TAGS_SUCCESS_MESSAGE = s__( ...@@ -16,8 +16,15 @@ export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
); );
export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags'); 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 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_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected'); 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 "" ...@@ -6207,6 +6207,9 @@ msgstr ""
msgid "ContainerRegistry|Cleanup policy:" msgid "ContainerRegistry|Cleanup policy:"
msgstr "" msgstr ""
msgid "ContainerRegistry|Configuration digest: %{digest}"
msgstr ""
msgid "ContainerRegistry|Container Registry" msgid "ContainerRegistry|Container Registry"
msgstr "" msgstr ""
...@@ -6222,6 +6225,9 @@ msgstr "" ...@@ -6222,6 +6225,9 @@ msgstr ""
msgid "ContainerRegistry|Delete selected" msgid "ContainerRegistry|Delete selected"
msgstr "" msgstr ""
msgid "ContainerRegistry|Digest: %{imageId}"
msgstr ""
msgid "ContainerRegistry|Docker connection error" msgid "ContainerRegistry|Docker connection error"
msgstr "" msgstr ""
...@@ -6246,9 +6252,6 @@ 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." 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: %{imageId}"
msgstr ""
msgid "ContainerRegistry|Image Repositories" msgid "ContainerRegistry|Image Repositories"
msgstr "" msgstr ""
...@@ -6258,6 +6261,9 @@ msgstr "" ...@@ -6258,6 +6261,9 @@ msgstr ""
msgid "ContainerRegistry|Login" msgid "ContainerRegistry|Login"
msgstr "" msgstr ""
msgid "ContainerRegistry|Manifest digest: %{digest}"
msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled" msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr "" msgstr ""
...@@ -6267,6 +6273,9 @@ msgstr "" ...@@ -6267,6 +6273,9 @@ msgstr ""
msgid "ContainerRegistry|Published %{timeInfo}" msgid "ContainerRegistry|Published %{timeInfo}"
msgstr "" msgstr ""
msgid "ContainerRegistry|Published to the %{repositoryPath} image repository at %{time} on %{date}"
msgstr ""
msgid "ContainerRegistry|Push an image" msgid "ContainerRegistry|Push an image"
msgstr "" 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', () => { ...@@ -29,7 +29,7 @@ describe('EmptyTagsState component', () => {
it('contains gl-empty-state', () => { it('contains gl-empty-state', () => {
mountComponent(); mountComponent();
expect(findEmptyState().exist()).toBe(true); expect(findEmptyState().exists()).toBe(true);
}); });
it('has the correct props', () => { it('has the correct props', () => {
......
import { shallowMount } from '@vue/test-utils'; import { shallowMount } from '@vue/test-utils';
import { GlFormCheckbox, GlSprintf } from '@gitlab/ui'; 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 ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import TimeAgoTooltip from '~/vue_shared/components/time_ago_tooltip.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 { import {
REMOVE_TAG_BUTTON_TITLE, REMOVE_TAG_BUTTON_TITLE,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP, REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive'; import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { tagsListResponse } from '../../mock_data'; import { tagsListResponse } from '../../mock_data';
import { ListItem } from '../../stubs';
describe('tags list row', () => { describe('tags list row', () => {
let wrapper; let wrapper;
...@@ -24,16 +25,21 @@ describe('tags list row', () => { ...@@ -24,16 +25,21 @@ describe('tags list row', () => {
const findName = () => wrapper.find('[data-testid="name"]'); const findName = () => wrapper.find('[data-testid="name"]');
const findSize = () => wrapper.find('[data-testid="size"]'); const findSize = () => wrapper.find('[data-testid="size"]');
const findTime = () => wrapper.find('[data-testid="time"]'); 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 findClipboardButton = () => wrapper.find(ClipboardButton);
const findDeleteButton = () => wrapper.find(DeleteButton); const findDeleteButton = () => wrapper.find(DeleteButton);
const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip); 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) => { const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMount(component, { wrapper = shallowMount(component, {
stubs: { stubs: {
GlSprintf, GlSprintf,
ListItem, ListItem,
DetailsRow,
}, },
propsData, propsData,
directives: { directives: {
...@@ -114,6 +120,7 @@ describe('tags list row', () => { ...@@ -114,6 +120,7 @@ describe('tags list row', () => {
it('is hidden if tag does not have a location', () => { it('is hidden if tag does not have a location', () => {
mountComponent({ ...defaultProps, tag: { ...tag, location: null } }); mountComponent({ ...defaultProps, tag: { ...tag, location: null } });
expect(findClipboardButton().exists()).toBe(false); expect(findClipboardButton().exists()).toBe(false);
}); });
...@@ -136,21 +143,25 @@ describe('tags list row', () => { ...@@ -136,21 +143,25 @@ describe('tags list row', () => {
it('contains the total_size and layers', () => { it('contains the total_size and layers', () => {
mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } }); mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024 } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers'); expect(findSize().text()).toMatchInterpolatedText('1.00 KiB · 10 layers');
}); });
it('when total_size is missing', () => { it('when total_size is missing', () => {
mountComponent(); mountComponent();
expect(findSize().text()).toMatchInterpolatedText('10 layers'); expect(findSize().text()).toMatchInterpolatedText('10 layers');
}); });
it('when layers are missing', () => { it('when layers are missing', () => {
mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } }); mountComponent({ ...defaultProps, tag: { ...tag, total_size: 1024, layers: null } });
expect(findSize().text()).toMatchInterpolatedText('1.00 KiB'); expect(findSize().text()).toMatchInterpolatedText('1.00 KiB');
}); });
it('when there is 1 layer', () => { it('when there is 1 layer', () => {
mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } }); mountComponent({ ...defaultProps, tag: { ...tag, layers: 1 } });
expect(findSize().text()).toMatchInterpolatedText('1 layer'); expect(findSize().text()).toMatchInterpolatedText('1 layer');
}); });
}); });
...@@ -181,7 +192,7 @@ describe('tags list row', () => { ...@@ -181,7 +192,7 @@ describe('tags list row', () => {
}); });
}); });
describe('shortRevision', () => { describe('digest', () => {
it('exists', () => { it('exists', () => {
mountComponent(); mountComponent();
...@@ -191,7 +202,7 @@ describe('tags list row', () => { ...@@ -191,7 +202,7 @@ describe('tags list row', () => {
it('has the correct text', () => { it('has the correct text', () => {
mountComponent(); mountComponent();
expect(findShortRevision().text()).toMatchInterpolatedText('Image ID: b118ab5b0'); expect(findShortRevision().text()).toMatchInterpolatedText('Digest: 1ab51d5');
}); });
}); });
...@@ -226,4 +237,39 @@ describe('tags list row', () => { ...@@ -226,4 +237,39 @@ describe('tags list row', () => {
expect(wrapper.emitted('delete')).toEqual([[]]); 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 = { ...@@ -70,9 +70,10 @@ export const tagsListResponse = {
size: 19, size: 19,
layers: 10, layers: 10,
location: 'location', location: 'location',
path: 'bar', path: 'bar:centos6',
created_at: '1505828744434', created_at: '2020-06-29T10:23:51.766+00:00',
destroy_path: 'path', destroy_path: 'path',
digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7786dfd5c',
}, },
{ {
name: 'test-tag', name: 'test-tag',
...@@ -80,9 +81,10 @@ export const tagsListResponse = { ...@@ -80,9 +81,10 @@ export const tagsListResponse = {
short_revision: 'b969de599', short_revision: 'b969de599',
size: 19, size: 19,
layers: 10, layers: 10,
path: 'foo', path: 'foo:test-tag',
location: 'location-2', location: 'location-2',
created_at: '1505828744434', created_at: '2020-06-29T10:23:51.766+00:00',
digest: 'sha256:1ab51d519f574b636ae7788051c60239334ae8622a9fd82a0cf7bae7736dfd5c',
}, },
], ],
headers, headers,
......
import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue'; import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
import RealListItem from '~/registry/explorer/components/list_item.vue';
export const GlModal = { export const GlModal = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>', template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
...@@ -29,3 +30,13 @@ export const GlSkeletonLoader = { ...@@ -29,3 +30,13 @@ export const GlSkeletonLoader = {
template: `<div><slot></slot></div>`, template: `<div><slot></slot></div>`,
props: ['width', 'height'], 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