Commit db6f6337 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '216931-convert-the-image-tag-ui-from-a-table-to-a-list-view-component' into 'master'

Convert the Image tag UI  to a list view

See merge request gitlab-org/gitlab!35138
parents 0aaaa397 a86f51be
<script>
import { GlButton } from '@gitlab/ui';
import TagsListRow from './tags_list_row.vue';
import { REMOVE_TAGS_BUTTON_TITLE, TAGS_LIST_TITLE } from '../../constants/index';
export default {
components: {
GlButton,
TagsListRow,
},
props: {
tags: {
type: Array,
required: false,
default: () => [],
},
isDesktop: {
type: Boolean,
default: false,
required: false,
},
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
TAGS_LIST_TITLE,
},
data() {
return {
selectedItems: {},
};
},
computed: {
hasSelectedItems() {
return this.tags.some(tag => this.selectedItems[tag.name]);
},
},
methods: {
updateSelectedItems(name) {
this.$set(this.selectedItems, name, !this.selectedItems[name]);
},
},
};
</script>
<template>
<div>
<div class="gl-display-flex gl-justify-content-space-between gl-mb-3">
<h5 data-testid="list-title">
{{ $options.i18n.TAGS_LIST_TITLE }}
</h5>
<gl-button
v-if="isDesktop"
:disabled="!hasSelectedItems"
category="secondary"
variant="danger"
@click="$emit('delete', selectedItems)"
>
{{ $options.i18n.REMOVE_TAGS_BUTTON_TITLE }}
</gl-button>
</div>
<tags-list-row
v-for="(tag, index) in tags"
:key="tag.path"
:tag="tag"
:index="index"
:selected="selectedItems[tag.name]"
:is-desktop="isDesktop"
@select="updateSelectedItems(tag.name)"
@delete="$emit('delete', { [tag.name]: true })"
/>
</div>
</template>
<script>
import { GlFormCheckbox, GlTooltipDirective, GlSprintf } from '@gitlab/ui';
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 DeleteButton from '../delete_button.vue';
import ListItem from '../list_item.vue';
import {
REMOVE_TAG_BUTTON_TITLE,
SHORT_REVISION_LABEL,
CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
} from '../../constants/index';
export default {
components: {
GlSprintf,
GlFormCheckbox,
DeleteButton,
ListItem,
ClipboardButton,
TimeAgoTooltip,
},
directives: {
GlTooltip: GlTooltipDirective,
},
props: {
tag: {
type: Object,
required: true,
},
index: {
type: Number,
required: true,
},
selected: {
type: Boolean,
default: false,
required: false,
},
isDesktop: {
type: Boolean,
default: false,
required: false,
},
},
i18n: {
REMOVE_TAG_BUTTON_TITLE,
SHORT_REVISION_LABEL,
CREATED_AT_LABEL,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
},
computed: {
formattedSize() {
return this.tag.total_size ? numberToHumanSize(this.tag.total_size) : '';
},
layers() {
return this.tag.layers ? n__('%d layer', '%d layers', this.tag.layers) : '';
},
mobileClasses() {
return this.isDesktop ? '' : 'mw-s';
},
},
};
</script>
<template>
<list-item :index="index" :selected="selected">
<template #left-action>
<gl-form-checkbox class="gl-m-0" :checked="selected" @change="$emit('select')" />
</template>
<template #left-primary>
<div class="gl-display-flex gl-align-items-center">
<div
v-gl-tooltip="{ title: tag.name }"
data-testid="name"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
:class="mobileClasses"
>
{{ tag.name }}
</div>
<clipboard-button
v-if="tag.location"
:title="tag.location"
:text="tag.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
</template>
<template #left-secondary>
<span data-testid="size">
{{ formattedSize }}
<template v-if="formattedSize && layers"
>&middot;</template
>
{{ layers }}
</span>
</template>
<template #right-primary>
<span data-testid="time">
<gl-sprintf :message="$options.i18n.CREATED_AT_LABEL">
<template #timeInfo>
<time-ago-tooltip :time="tag.created_at" />
</template>
</gl-sprintf>
</span>
</template>
<template #right-secondary>
<span data-testid="short-revision">
<gl-sprintf :message="$options.i18n.SHORT_REVISION_LABEL">
<template #imageId>{{ tag.short_revision }}</template>
</gl-sprintf>
</span>
</template>
<template #right-action>
<delete-button
:disabled="!tag.destroy_path"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:tooltip-title="$options.i18n.REMOVE_TAG_BUTTON_DISABLE_TOOLTIP"
:tooltip-disabled="Boolean(tag.destroy_path)"
data-testid="single-delete-button"
@delete="$emit('delete')"
/>
</template>
</list-item>
</template>
<script>
import { GlTable, GlFormCheckbox, GlButton, GlTooltipDirective } from '@gitlab/ui';
import { n__ } from '~/locale';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago';
import {
LIST_KEY_TAG,
LIST_KEY_IMAGE_ID,
LIST_KEY_SIZE,
LIST_KEY_LAST_UPDATED,
LIST_KEY_ACTIONS,
LIST_KEY_CHECKBOX,
LIST_LABEL_TAG,
LIST_LABEL_IMAGE_ID,
LIST_LABEL_SIZE,
LIST_LABEL_LAST_UPDATED,
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
} from '../../constants/index';
export default {
components: {
GlTable,
GlFormCheckbox,
GlButton,
ClipboardButton,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [timeagoMixin],
props: {
tags: {
type: Array,
required: false,
default: () => [],
},
isLoading: {
type: Boolean,
required: false,
default: false,
},
isDesktop: {
type: Boolean,
required: false,
default: false,
},
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
},
data() {
return {
selectedItems: [],
};
},
computed: {
fields() {
const tagClass = this.isDesktop ? 'w-25' : '';
const tagInnerClass = this.isDesktop ? 'mw-m' : 'gl-justify-content-end';
return [
{ key: LIST_KEY_CHECKBOX, label: '', class: 'gl-w-16' },
{
key: LIST_KEY_TAG,
label: LIST_LABEL_TAG,
class: `${tagClass} js-tag-column`,
innerClass: tagInnerClass,
},
{ key: LIST_KEY_IMAGE_ID, label: LIST_LABEL_IMAGE_ID },
{ key: LIST_KEY_SIZE, label: LIST_LABEL_SIZE },
{ key: LIST_KEY_LAST_UPDATED, label: LIST_LABEL_LAST_UPDATED },
{ key: LIST_KEY_ACTIONS, label: '' },
].filter(f => f.key !== LIST_KEY_CHECKBOX || this.isDesktop);
},
tagsNames() {
return this.tags.map(t => t.name);
},
selectAllChecked() {
return this.selectedItems.length === this.tags.length && this.tags.length > 0;
},
},
watch: {
tagsNames: {
immediate: false,
handler(tagsNames) {
this.selectedItems = this.selectedItems.filter(t => tagsNames.includes(t));
},
},
},
methods: {
formatSize(size) {
return numberToHumanSize(size);
},
layers(layers) {
return layers ? n__('%d layer', '%d layers', layers) : '';
},
onSelectAllChange() {
if (this.selectAllChecked) {
this.selectedItems = [];
} else {
this.selectedItems = this.tags.map(x => x.name);
}
},
updateSelectedItems(name) {
const delIndex = this.selectedItems.findIndex(x => x === name);
if (delIndex > -1) {
this.selectedItems.splice(delIndex, 1);
} else {
this.selectedItems.push(name);
}
},
},
};
</script>
<template>
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty :busy="isLoading">
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
data-testid="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<span class="gl-display-flex gl-justify-content-end">
<gl-button
v-gl-tooltip
data-testid="bulkDeleteButton"
:disabled="!selectedItems || selectedItems.length === 0"
icon="remove"
variant="danger"
:title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
@click="$emit('delete', selectedItems)"
/>
</span>
</template>
<template #cell(checkbox)="{item}">
<gl-form-checkbox
data-testid="rowCheckbox"
:checked="selectedItems.includes(item.name)"
@change="updateSelectedItems(item.name)"
/>
</template>
<template #cell(name)="{item, field}">
<div data-testid="rowName" :class="[field.innerClass, 'gl-display-flex']">
<span
v-gl-tooltip
data-testid="rowNameText"
:title="item.name"
class="gl-text-overflow-ellipsis gl-overflow-hidden gl-white-space-nowrap"
>
{{ item.name }}
</span>
<clipboard-button
v-if="item.location"
data-testid="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
</template>
<template #cell(short_revision)="{value}">
<span data-testid="rowShortRevision">
{{ value }}
</span>
</template>
<template #cell(total_size)="{item}">
<span data-testid="rowSize">
{{ formatSize(item.total_size) }}
<template v-if="item.total_size && item.layers">
&middot;
</template>
{{ layers(item.layers) }}
</span>
</template>
<template #cell(created_at)="{value}">
<span v-gl-tooltip data-testid="rowTime" :title="tooltipTitle(value)">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{item}">
<span class="gl-display-flex gl-justify-content-end">
<gl-button
data-testid="singleDeleteButton"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:disabled="!item.destroy_path"
variant="danger"
icon="remove"
category="secondary"
@click="$emit('delete', [item.name])"
/>
</span>
</template>
<template #empty>
<slot name="empty"></slot>
</template>
<template #table-busy>
<slot name="loader"></slot>
</template>
</gl-table>
</template>
......@@ -12,12 +12,19 @@ export default {
default: false,
required: false,
},
selected: {
type: Boolean,
default: false,
required: false,
},
},
computed: {
optionalClasses() {
return {
'gl-border-t-solid gl-border-t-1': this.index === 0,
'disabled-content': this.disabled,
'gl-border-gray-200': !this.selected,
'gl-bg-blue-50 gl-border-blue-200': this.selected,
};
},
},
......@@ -26,22 +33,36 @@ export default {
<template>
<div
:class="[
'gl-display-flex gl-justify-content-space-between gl-align-items-center gl-py-2 gl-px-1 gl-border-gray-200 gl-border-b-solid gl-border-b-1 gl-py-4',
optionalClasses,
]"
class="gl-display-flex gl-align-items-center gl-border-b-solid gl-border-b-1 gl-py-4 gl-px-2"
:class="optionalClasses"
>
<div v-if="$slots['left-action']" class="gl-mr-5 gl-display-none gl-display-sm-block">
<slot name="left-action"></slot>
</div>
<div class="gl-display-flex gl-flex-direction-column gl-flex-fill-1">
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-text-black-normal gl-font-weight-bold"
>
<div class="gl-display-flex gl-flex-direction-column">
<div class="gl-display-flex gl-align-items-center">
<div>
<slot name="left-primary"></slot>
</div>
<div class="gl-font-sm gl-text-gray-500">
<slot name="left-secondary"></slot>
<div>
<slot name="right-primary"></slot>
</div>
</div>
<div
class="gl-display-flex gl-align-items-center gl-justify-content-space-between gl-font-sm gl-text-gray-500"
>
<div>
<slot name="right"></slot>
<slot name="left-secondary"></slot>
</div>
<div>
<slot name="right-secondary"></slot>
</div>
</div>
</div>
<div v-if="$slots['right-action']" class="gl-ml-5 gl-display-none gl-display-sm-block">
<slot name="right-action"></slot>
</div>
</div>
</template>
......@@ -106,9 +106,8 @@ export default {
</gl-sprintf>
</span>
</template>
<template #right>
<template #right-action>
<delete-button
class="gl-display-none d-sm-block"
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:disabled="disabledDelete"
:tooltip-disabled="Boolean(item.destroy_path)"
......
......@@ -14,12 +14,13 @@ export const DELETE_TAGS_ERROR_MESSAGE = s__(
export const DELETE_TAGS_SUCCESS_MESSAGE = s__(
'ContainerRegistry|Tags successfully marked for deletion.',
);
export const LIST_LABEL_TAG = s__('ContainerRegistry|Tag');
export const LIST_LABEL_IMAGE_ID = s__('ContainerRegistry|Image ID');
export const LIST_LABEL_SIZE = s__('ContainerRegistry|Compressed Size');
export const LIST_LABEL_LAST_UPDATED = s__('ContainerRegistry|Last Updated');
export const TAGS_LIST_TITLE = s__('ContainerRegistry|Image tags');
export const SHORT_REVISION_LABEL = s__('ContainerRegistry|Image ID: %{imageId}');
export const CREATED_AT_LABEL = s__('ContainerRegistry|Published %{timeInfo}');
export const REMOVE_TAG_BUTTON_TITLE = s__('ContainerRegistry|Remove tag');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Remove selected tags');
export const REMOVE_TAGS_BUTTON_TITLE = s__('ContainerRegistry|Delete selected');
export const REMOVE_TAG_CONFIRMATION_TEXT = s__(
`ContainerRegistry|You are about to remove %{item}. Are you sure?`,
);
......@@ -36,17 +37,15 @@ export const ADMIN_GARBAGE_COLLECTION_TIP = s__(
'ContainerRegistry|Remember to run %{docLinkStart}garbage collection%{docLinkEnd} to remove the stale data from storage.',
);
export const REMOVE_TAG_BUTTON_DISABLE_TOOLTIP = s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
);
// Parameters
export const DEFAULT_PAGE = 1;
export const DEFAULT_PAGE_SIZE = 10;
export const GROUP_PAGE_TYPE = 'groups';
export const LIST_KEY_TAG = 'name';
export const LIST_KEY_IMAGE_ID = 'short_revision';
export const LIST_KEY_SIZE = 'total_size';
export const LIST_KEY_LAST_UPDATED = 'created_at';
export const LIST_KEY_ACTIONS = 'actions';
export const LIST_KEY_CHECKBOX = 'checkbox';
export const ALERT_SUCCESS_TAG = 'success_tag';
export const ALERT_DANGER_TAG = 'danger_tag';
export const ALERT_SUCCESS_TAGS = 'success_tags';
......
......@@ -6,7 +6,7 @@ import Tracking from '~/tracking';
import DeleteAlert from '../components/details_page/delete_alert.vue';
import DeleteModal from '../components/details_page/delete_modal.vue';
import DetailsHeader from '../components/details_page/details_header.vue';
import TagsTable from '../components/details_page/tags_table.vue';
import TagsList from '../components/details_page/tags_list.vue';
import TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
......@@ -24,7 +24,7 @@ export default {
DetailsHeader,
GlPagination,
DeleteModal,
TagsTable,
TagsList,
TagsLoader,
EmptyTagsState,
},
......@@ -65,10 +65,8 @@ export default {
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
deleteTags(toBeDeletedList) {
this.itemsToBeDeleted = toBeDeletedList.map(name => ({
...this.tags.find(t => t.name === name),
}));
deleteTags(toBeDeleted) {
this.itemsToBeDeleted = this.tags.filter(tag => toBeDeleted[tag.name]);
this.track('click_button');
this.$refs.deleteModal.show();
},
......@@ -114,24 +112,21 @@ export default {
</script>
<template>
<div v-gl-resize-observer="handleResize" class="my-3 w-100 slide-enter-to-element">
<div v-gl-resize-observer="handleResize" class="gl-my-3 gl-w-full slide-enter-to-element">
<delete-alert
v-model="deleteAlertType"
:garbage-collection-help-page-path="config.garbageCollectionHelpPagePath"
:is-admin="config.isAdmin"
class="my-2"
class="gl-my-2"
/>
<details-header :image-name="imageName" />
<tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags">
<template #empty>
<empty-tags-state :no-containers-image="config.noContainersImage" />
<tags-loader v-if="isLoading" />
<template v-else>
<empty-tags-state v-if="tags.length === 0" :no-containers-image="config.noContainersImage" />
<tags-list v-else :tags="tags" :is-desktop="isDesktop" @delete="deleteTags" />
</template>
<template #loader>
<tags-loader v-once />
</template>
</tags-table>
<gl-pagination
v-if="!isLoading"
......@@ -140,7 +135,7 @@ export default {
:per-page="tagsPagination.perPage"
:total-items="tagsPagination.total"
align="center"
class="w-100"
class="gl-w-full gl-mt-3"
/>
<delete-modal
......
---
title: Convert the Image tag UI from a table to a list view
merge_request: 35138
author:
type: changed
......@@ -6082,9 +6082,6 @@ msgstr ""
msgid "ContainerRegistry|CLI Commands"
msgstr ""
msgid "ContainerRegistry|Compressed Size"
msgstr ""
msgid "ContainerRegistry|Container Registry"
msgstr ""
......@@ -6100,6 +6097,9 @@ msgstr ""
msgid "ContainerRegistry|Copy push command"
msgstr ""
msgid "ContainerRegistry|Delete selected"
msgstr ""
msgid "ContainerRegistry|Docker connection error"
msgstr ""
......@@ -6133,16 +6133,16 @@ 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"
msgid "ContainerRegistry|Image ID: %{imageId}"
msgstr ""
msgid "ContainerRegistry|Image Repositories"
msgstr ""
msgid "ContainerRegistry|Keep and protect the images that matter most."
msgid "ContainerRegistry|Image tags"
msgstr ""
msgid "ContainerRegistry|Last Updated"
msgid "ContainerRegistry|Keep and protect the images that matter most."
msgstr ""
msgid "ContainerRegistry|Login"
......@@ -6157,6 +6157,9 @@ msgstr ""
msgid "ContainerRegistry|Please contact your administrator."
msgstr ""
msgid "ContainerRegistry|Published %{timeInfo}"
msgstr ""
msgid "ContainerRegistry|Push an image"
msgstr ""
......@@ -6172,9 +6175,6 @@ msgstr ""
msgid "ContainerRegistry|Remove repository"
msgstr ""
msgid "ContainerRegistry|Remove selected tags"
msgstr ""
msgid "ContainerRegistry|Remove tag"
msgid_plural "ContainerRegistry|Remove tags"
msgstr[0] ""
......@@ -6204,9 +6204,6 @@ msgstr ""
msgid "ContainerRegistry|Sorry, your filter produced no results."
msgstr ""
msgid "ContainerRegistry|Tag"
msgstr ""
msgid "ContainerRegistry|Tag expiration policy"
msgstr ""
......
......@@ -82,7 +82,7 @@ RSpec.describe 'Container Registry', :js do
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['latest']) { service }
first('[data-testid="singleDeleteButton"]').click
first('[data-testid="single-delete-button"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
......
......@@ -84,7 +84,7 @@ RSpec.describe 'Container Registry', :js do
expect(service).to receive(:execute).with(container_repository) { { status: :success } }
expect(Projects::ContainerRepository::DeleteTagsService).to receive(:new).with(container_repository.project, user, tags: ['1']) { service }
first('[data-testid="singleDeleteButton"]').click
first('[data-testid="single-delete-button"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
......
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 {
REMOVE_TAG_BUTTON_TITLE,
REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
} from '~/registry/explorer/constants/index';
import { createMockDirective, getBinding } from 'helpers/vue_mock_directive';
import { tagsListResponse } from '../../mock_data';
describe('tags list row', () => {
let wrapper;
const [tag] = [...tagsListResponse.data];
const defaultProps = { tag, isDesktop: true, index: 0 };
const findCheckbox = () => wrapper.find(GlFormCheckbox);
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 findClipboardButton = () => wrapper.find(ClipboardButton);
const findDeleteButton = () => wrapper.find(DeleteButton);
const findTimeAgoTooltip = () => wrapper.find(TimeAgoTooltip);
const mountComponent = (propsData = defaultProps) => {
wrapper = shallowMount(component, {
stubs: {
GlSprintf,
ListItem,
},
propsData,
directives: {
GlTooltip: createMockDirective(),
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('checkbox', () => {
it('exists', () => {
mountComponent();
expect(findCheckbox().exists()).toBe(true);
});
it('is wired to the selected prop', () => {
mountComponent({ ...defaultProps, selected: true });
expect(findCheckbox().attributes('checked')).toBe('true');
});
it('when changed emit a select event', () => {
mountComponent();
findCheckbox().vm.$emit('change');
expect(wrapper.emitted('select')).toEqual([[]]);
});
});
describe('tag name', () => {
it('exists', () => {
mountComponent();
expect(findName().exists()).toBe(true);
});
it('has the correct text', () => {
mountComponent();
expect(findName().text()).toBe(tag.name);
});
it('has a tooltip', () => {
mountComponent();
const tooltip = getBinding(findName().element, 'gl-tooltip');
expect(tooltip.value.title).toBe(tag.name);
});
it('on mobile has mw-s class', () => {
mountComponent({ ...defaultProps, isDesktop: false });
expect(findName().classes('mw-s')).toBe(true);
});
});
describe('clipboard button', () => {
it('exist if tag.location exist', () => {
mountComponent();
expect(findClipboardButton().exists()).toBe(true);
});
it('is hidden if tag does not have a location', () => {
mountComponent({ ...defaultProps, tag: { ...tag, location: null } });
expect(findClipboardButton().exists()).toBe(false);
});
it('has the correct props/attributes', () => {
mountComponent();
expect(findClipboardButton().attributes()).toMatchObject({
text: 'location',
title: 'location',
});
});
});
describe('size', () => {
it('exists', () => {
mountComponent();
expect(findSize().exists()).toBe(true);
});
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');
});
});
describe('time', () => {
it('exists', () => {
mountComponent();
expect(findTime().exists()).toBe(true);
});
it('has the correct text', () => {
mountComponent();
expect(findTime().text()).toBe('Published');
});
it('contains time_ago_tooltip component', () => {
mountComponent();
expect(findTimeAgoTooltip().exists()).toBe(true);
});
it('pass the correct props to time ago tooltip', () => {
mountComponent();
expect(findTimeAgoTooltip().attributes()).toMatchObject({ time: tag.created_at });
});
});
describe('shortRevision', () => {
it('exists', () => {
mountComponent();
expect(findShortRevision().exists()).toBe(true);
});
it('has the correct text', () => {
mountComponent();
expect(findShortRevision().text()).toMatchInterpolatedText('Image ID: b118ab5b0');
});
});
describe('delete button', () => {
it('exists', () => {
mountComponent();
expect(findDeleteButton().exists()).toBe(true);
});
it('has the correct props/attributes', () => {
mountComponent();
expect(findDeleteButton().attributes()).toMatchObject({
title: REMOVE_TAG_BUTTON_TITLE,
tooltiptitle: REMOVE_TAG_BUTTON_DISABLE_TOOLTIP,
tooltipdisabled: 'true',
});
});
it('is disabled when tag has no destroy path', () => {
mountComponent({ ...defaultProps, tag: { ...tag, destroy_path: null } });
expect(findDeleteButton().attributes('disabled')).toBe('true');
});
it('delete event emits delete', () => {
mountComponent();
findDeleteButton().vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[]]);
});
});
});
import { shallowMount } from '@vue/test-utils';
import { GlButton } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/tags_list.vue';
import TagsListRow from '~/registry/explorer/components/details_page/tags_list_row.vue';
import { TAGS_LIST_TITLE, REMOVE_TAGS_BUTTON_TITLE } from '~/registry/explorer/constants/index';
import { tagsListResponse } from '../../mock_data';
describe('Tags List', () => {
let wrapper;
const tags = [...tagsListResponse.data];
const findTagsListRow = () => wrapper.findAll(TagsListRow);
const findDeleteButton = () => wrapper.find(GlButton);
const findListTitle = () => wrapper.find('[data-testid="list-title"]');
const mountComponent = (propsData = { tags, isDesktop: true }) => {
wrapper = shallowMount(component, {
propsData,
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('List title', () => {
it('exists', () => {
mountComponent();
expect(findListTitle().exists()).toBe(true);
});
it('has the correct text', () => {
mountComponent();
expect(findListTitle().text()).toBe(TAGS_LIST_TITLE);
});
});
describe('delete button', () => {
it('is not shown on mobile view', () => {
mountComponent({ tags, isDesktop: false });
expect(findDeleteButton().exists()).toBe(false);
});
it('is shown on desktop view', () => {
mountComponent();
expect(findDeleteButton().exists()).toBe(true);
});
it('has the correct text', () => {
mountComponent();
expect(findDeleteButton().text()).toBe(REMOVE_TAGS_BUTTON_TITLE);
});
it('has the correct props', () => {
mountComponent();
expect(findDeleteButton().attributes()).toMatchObject({
category: 'secondary',
variant: 'danger',
});
});
it('is disabled when no item is selected', () => {
mountComponent();
expect(findDeleteButton().attributes('disabled')).toBe('true');
});
it('is enabled when at least one item is selected', async () => {
mountComponent();
findTagsListRow()
.at(0)
.vm.$emit('select');
await wrapper.vm.$nextTick();
expect(findDeleteButton().attributes('disabled')).toBe(undefined);
});
it('click event emits a deleted event with selected items', () => {
mountComponent();
findTagsListRow()
.at(0)
.vm.$emit('select');
findDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]);
});
});
describe('list rows', () => {
it('one row exist for each tag', () => {
mountComponent();
expect(findTagsListRow()).toHaveLength(tags.length);
});
it('the correct props are bound to it', () => {
mountComponent();
expect(
findTagsListRow()
.at(0)
.attributes(),
).toMatchObject({
index: '0',
isdesktop: 'true',
});
});
describe('events', () => {
it('select event update the selected items', async () => {
mountComponent();
findTagsListRow()
.at(0)
.vm.$emit('select');
await wrapper.vm.$nextTick();
expect(
findTagsListRow()
.at(0)
.attributes('selected'),
).toBe('true');
});
it('delete event emit a delete event', () => {
mountComponent();
findTagsListRow()
.at(0)
.vm.$emit('delete');
expect(wrapper.emitted('delete')).toEqual([[{ centos6: true }]]);
});
});
});
});
import { mount } from '@vue/test-utils';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/components/details_page/tags_table.vue';
import { tagsListResponse } from '../../mock_data';
describe('tags_table', () => {
let wrapper;
const tags = [...tagsListResponse.data];
const findMainCheckbox = () => wrapper.find('[data-testid="mainCheckbox"]');
const findFirstRowItem = testid => wrapper.find(`[data-testid="${testid}"]`);
const findBulkDeleteButton = () => wrapper.find('[data-testid="bulkDeleteButton"]');
const findAllDeleteButtons = () => wrapper.findAll('[data-testid="singleDeleteButton"]');
const findAllCheckboxes = () => wrapper.findAll('[data-testid="rowCheckbox"]');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
const findLoaderSlot = () => wrapper.find('[data-testid="loaderSlot"]');
const findEmptySlot = () => wrapper.find('[data-testid="emptySlot"]');
const mountComponent = (propsData = { tags, isDesktop: true }) => {
wrapper = mount(component, {
stubs: {
...stubChildren(component),
GlTable: false,
},
propsData,
slots: {
loader: '<div data-testid="loaderSlot"></div>',
empty: '<div data-testid="emptySlot"></div>',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it.each([
'rowCheckbox',
'rowName',
'rowShortRevision',
'rowSize',
'rowTime',
'singleDeleteButton',
])('%s exist in the table', element => {
mountComponent();
expect(findFirstRowItem(element).exists()).toBe(true);
});
describe('header checkbox', () => {
it('exists', () => {
mountComponent();
expect(findMainCheckbox().exists()).toBe(true);
});
it('if selected selects all the rows', () => {
mountComponent();
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
expect(findCheckedCheckboxes()).toHaveLength(tags.length);
});
});
it('if deselect deselects all the row', () => {
mountComponent();
findMainCheckbox().vm.$emit('change');
return wrapper.vm
.$nextTick()
.then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(findMainCheckbox().attributes('checked')).toBe(undefined);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
});
describe('row checkbox', () => {
beforeEach(() => {
mountComponent();
});
it('selecting and deselecting the checkbox works as intended', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm
.$nextTick()
.then(() => {
expect(wrapper.vm.selectedItems).toEqual([tags[0].name]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick();
})
.then(() => {
expect(wrapper.vm.selectedItems.length).toBe(0);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBe(undefined);
});
});
});
describe('header delete button', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(findBulkDeleteButton().exists()).toBe(true);
});
it('is disabled if no item is selected', () => {
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
});
it('is enabled if at least one item is selected', () => {
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy();
});
});
describe('on click', () => {
it('when one item is selected', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[['centos6']]]);
});
it('when multiple items are selected', () => {
findMainCheckbox().vm.$emit('change');
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[tags.map(t => t.name)]]);
});
});
});
describe('row delete button', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(
findAllDeleteButtons()
.at(0)
.exists(),
).toBe(true);
});
it('is disabled if the item has no destroy_path', () => {
expect(
findAllDeleteButtons()
.at(1)
.attributes('disabled'),
).toBe('true');
});
it('on click', () => {
findAllDeleteButtons()
.at(0)
.vm.$emit('click');
expect(wrapper.emitted('delete')).toEqual([[['centos6']]]);
});
});
describe('name cell', () => {
it('tag column has a tooltip with the tag name', () => {
mountComponent();
expect(findFirstTagNameText().attributes('title')).toBe(tagsListResponse.data[0].name);
});
describe('on desktop viewport', () => {
beforeEach(() => {
mountComponent();
});
it('table header has class w-25', () => {
expect(findFirsTagColumn().classes()).toContain('w-25');
});
it('tag column has the mw-m class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('mw-m');
});
});
describe('on mobile viewport', () => {
beforeEach(() => {
mountComponent({ tags, isDesktop: false });
});
it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
});
it('tag column has the gl-justify-content-end class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
});
});
});
describe('last updated cell', () => {
let timeCell;
beforeEach(() => {
mountComponent();
timeCell = findFirstRowItem('rowTime');
});
it('displays the time in string format', () => {
expect(timeCell.text()).toBe('2 years ago');
});
it('has a tooltip timestamp', () => {
expect(timeCell.attributes('title')).toBe('Sep 19, 2017 1:45pm GMT+0000');
});
});
describe('empty state slot', () => {
describe('when the table is empty', () => {
beforeEach(() => {
mountComponent({ tags: [], isDesktop: true });
});
it('does not show table rows', () => {
expect(findFirstTagNameText().exists()).toBe(false);
});
it('has the empty state slot', () => {
expect(findEmptySlot().exists()).toBe(true);
});
});
describe('when the table is not empty', () => {
beforeEach(() => {
mountComponent({ tags, isDesktop: true });
});
it('does show table rows', () => {
expect(findFirstTagNameText().exists()).toBe(true);
});
it('does not show the empty state', () => {
expect(findEmptySlot().exists()).toBe(false);
});
});
});
describe('loader slot', () => {
describe('when the data is loading', () => {
beforeEach(() => {
mountComponent({ isLoading: true, tags });
});
it('show the loader', () => {
expect(findLoaderSlot().exists()).toBe(true);
});
it('does not show the table rows', () => {
expect(findFirstTagNameText().exists()).toBe(false);
});
});
describe('when the data is not loading', () => {
beforeEach(() => {
mountComponent({ isLoading: false, tags });
});
it('does not show the loader', () => {
expect(findLoaderSlot().exists()).toBe(false);
});
it('shows the table rows', () => {
expect(findFirstTagNameText().exists()).toBe(true);
});
});
});
});
......@@ -4,17 +4,23 @@ import component from '~/registry/explorer/components/list_item.vue';
describe('list item', () => {
let wrapper;
const findLeftActionSlot = () => wrapper.find('[data-testid="left-action"]');
const findLeftPrimarySlot = () => wrapper.find('[data-testid="left-primary"]');
const findLeftSecondarySlot = () => wrapper.find('[data-testid="left-secondary"]');
const findRightSlot = () => wrapper.find('[data-testid="right"]');
const findRightPrimarySlot = () => wrapper.find('[data-testid="right-primary"]');
const findRightSecondarySlot = () => wrapper.find('[data-testid="right-secondary"]');
const findRightActionSlot = () => wrapper.find('[data-testid="right-action"]');
const mountComponent = propsData => {
wrapper = shallowMount(component, {
propsData,
slots: {
'left-action': '<div data-testid="left-action" />',
'left-primary': '<div data-testid="left-primary" />',
'left-secondary': '<div data-testid="left-secondary" />',
right: '<div data-testid="right" />',
'right-primary': '<div data-testid="right-primary" />',
'right-secondary': '<div data-testid="right-secondary" />',
'right-action': '<div data-testid="right-action" />',
},
});
};
......@@ -24,29 +30,30 @@ describe('list item', () => {
wrapper = null;
});
it('has a left primary slot', () => {
it.each`
slotName | finderFunction
${'left-primary'} | ${findLeftPrimarySlot}
${'left-secondary'} | ${findLeftSecondarySlot}
${'right-primary'} | ${findRightPrimarySlot}
${'right-secondary'} | ${findRightSecondarySlot}
${'left-action'} | ${findLeftActionSlot}
${'right-action'} | ${findRightActionSlot}
`('has a $slotName slot', ({ finderFunction }) => {
mountComponent();
expect(findLeftPrimarySlot().exists()).toBe(true);
});
it('has a left secondary slot', () => {
mountComponent();
expect(findLeftSecondarySlot().exists()).toBe(true);
});
it('has a right slot', () => {
mountComponent();
expect(findRightSlot().exists()).toBe(true);
expect(finderFunction().exists()).toBe(true);
});
describe('disabled prop', () => {
it('when true applies disabled-content class', () => {
mountComponent({ disabled: true });
expect(wrapper.classes('disabled-content')).toBe(true);
});
it('when false does not apply disabled-content class', () => {
mountComponent({ disabled: false });
expect(wrapper.classes('disabled-content')).toBe(false);
});
});
......@@ -54,6 +61,7 @@ describe('list item', () => {
describe('index prop', () => {
it('when index is 0 displays a top border', () => {
mountComponent({ index: 0 });
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['gl-border-t-solid', 'gl-border-t-1']),
);
......@@ -61,8 +69,30 @@ describe('list item', () => {
it('when index is not 0 hides top border', () => {
mountComponent({ index: 1 });
expect(wrapper.classes('gl-border-t-solid')).toBe(false);
expect(wrapper.classes('gl-border-t-1')).toBe(false);
expect(wrapper.classes()).toEqual(
expect.not.arrayContaining(['gl-border-t-solid', 'gl-border-t-1']),
);
});
});
describe('selected prop', () => {
it('when true applies the selected border and background', () => {
mountComponent({ selected: true });
expect(wrapper.classes()).toEqual(
expect.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']),
);
expect(wrapper.classes()).toEqual(expect.not.arrayContaining(['gl-border-gray-200']));
});
it('when false applies the default border', () => {
mountComponent({ selected: false });
expect(wrapper.classes()).toEqual(
expect.not.arrayContaining(['gl-bg-blue-50', 'gl-border-blue-200']),
);
expect(wrapper.classes()).toEqual(expect.arrayContaining(['gl-border-gray-200']));
});
});
});
......@@ -71,7 +71,7 @@ export const tagsListResponse = {
layers: 10,
location: 'location',
path: 'bar',
created_at: 1505828744434,
created_at: '1505828744434',
destroy_path: 'path',
},
{
......@@ -82,7 +82,7 @@ export const tagsListResponse = {
layers: 10,
path: 'foo',
location: 'location-2',
created_at: 1505828744434,
created_at: '1505828744434',
},
],
headers,
......
......@@ -5,6 +5,7 @@ import component from '~/registry/explorer/pages/details.vue';
import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue';
import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue';
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import TagsList from '~/registry/explorer/components/details_page/tags_list.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import { createStore } from '~/registry/explorer/stores/';
import {
......@@ -15,7 +16,7 @@ import {
} from '~/registry/explorer/stores/mutation_types/';
import { tagsListResponse } from '../mock_data';
import { TagsTable, DeleteModal } from '../stubs';
import { DeleteModal } from '../stubs';
describe('Details Page', () => {
let wrapper;
......@@ -25,18 +26,23 @@ describe('Details Page', () => {
const findDeleteModal = () => wrapper.find(DeleteModal);
const findPagination = () => wrapper.find(GlPagination);
const findTagsLoader = () => wrapper.find(TagsLoader);
const findTagsTable = () => wrapper.find(TagsTable);
const findTagsList = () => wrapper.find(TagsList);
const findDeleteAlert = () => wrapper.find(DeleteAlert);
const findDetailsHeader = () => wrapper.find(DetailsHeader);
const findEmptyTagsState = () => wrapper.find(EmptyTagsState);
const routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
const tagsArrayToSelectedTags = tags =>
tags.reduce((acc, c) => {
acc[c.name] = true;
return acc;
}, {});
const mountComponent = options => {
wrapper = shallowMount(component, {
store,
stubs: {
TagsTable,
DeleteModal,
},
mocks: {
......@@ -66,15 +72,18 @@ describe('Details Page', () => {
describe('when isLoading is true', () => {
beforeEach(() => {
mountComponent();
store.commit(SET_MAIN_LOADING, true);
return wrapper.vm.$nextTick();
mountComponent();
});
afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('binds isLoading to tags-table', () => {
expect(findTagsTable().props('isLoading')).toBe(true);
it('shows the loader', () => {
expect(findTagsLoader().exists()).toBe(true);
});
it('does not show the list', () => {
expect(findTagsList().exists()).toBe(false);
});
it('does not show pagination', () => {
......@@ -82,8 +91,9 @@ describe('Details Page', () => {
});
});
describe('table slots', () => {
describe('when the list of tags is empty', () => {
beforeEach(() => {
store.commit(SET_TAGS_LIST_SUCCESS, []);
mountComponent();
});
......@@ -91,32 +101,37 @@ describe('Details Page', () => {
expect(findEmptyTagsState().exists()).toBe(true);
});
it('has a skeleton loader', () => {
expect(findTagsLoader().exists()).toBe(true);
it('does not show the loader', () => {
expect(findTagsLoader().exists()).toBe(false);
});
it('does not show the list', () => {
expect(findTagsList().exists()).toBe(false);
});
});
describe('table', () => {
describe('list', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(findTagsTable().exists()).toBe(true);
expect(findTagsList().exists()).toBe(true);
});
it('has the correct props bound', () => {
expect(findTagsTable().props()).toMatchObject({
expect(findTagsList().props()).toMatchObject({
isDesktop: true,
isLoading: false,
tags: store.state.tags,
});
});
describe('deleteEvent', () => {
describe('single item', () => {
let tagToBeDeleted;
beforeEach(() => {
findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
[tagToBeDeleted] = store.state.tags;
findTagsList().vm.$emit('delete', { [tagToBeDeleted.name]: true });
});
it('open the modal', () => {
......@@ -124,7 +139,7 @@ describe('Details Page', () => {
});
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]);
expect(wrapper.vm.itemsToBeDeleted).toEqual([tagToBeDeleted]);
});
it('tracks a single delete event', () => {
......@@ -136,7 +151,7 @@ describe('Details Page', () => {
describe('multiple items', () => {
beforeEach(() => {
findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags));
});
it('open the modal', () => {
......@@ -202,7 +217,7 @@ describe('Details Page', () => {
describe('when one item is selected to be deleted', () => {
beforeEach(() => {
mountComponent();
findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
findTagsList().vm.$emit('delete', { [store.state.tags[0].name]: true });
});
it('dispatch requestDeleteTag with the right parameters', () => {
......@@ -217,7 +232,7 @@ describe('Details Page', () => {
describe('when more than one item is selected to be deleted', () => {
beforeEach(() => {
mountComponent();
findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
findTagsList().vm.$emit('delete', tagsArrayToSelectedTags(store.state.tags));
});
it('dispatch requestDeleteTags with the right parameters', () => {
......
import RealTagsTable from '~/registry/explorer/components/details_page/tags_table.vue';
import RealDeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
export const GlModal = {
......@@ -18,11 +17,6 @@ export const RouterLink = {
props: ['to'],
};
export const TagsTable = {
props: RealTagsTable.props,
template: `<div><slot name="empty"></slot><slot name="loader"></slot></div>`,
};
export const DeleteModal = {
template: '<div></div>',
methods: {
......
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