Commit 79f69682 authored by Mark Florian's avatar Mark Florian

Merge branch...

Merge branch '218545-refactor-container-registry-frontend-code-to-ease-community-contribution' into 'master'

Refactor tags table to own component

Closes #219792

See merge request gitlab-org/gitlab!34059
parents 00da6587 c8a07385
<script>
import { GlEmptyState } from '@gitlab/ui';
import {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
} from '../../constants/index';
export default {
components: {
GlEmptyState,
},
props: {
noContainersImage: {
type: String,
required: false,
default: '',
},
},
i18n: {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
},
};
</script>
<template>
<gl-empty-state
:title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:svg-path="noContainersImage"
:description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
class="gl-mx-auto gl-my-0"
/>
</template>
<script>
import { GlSkeletonLoader } from '@gitlab/ui';
export default {
components: {
GlSkeletonLoader,
},
loader: {
repeat: 10,
width: 1000,
height: 40,
},
};
</script>
<template>
<div>
<gl-skeleton-loader
v-for="index in $options.loader.repeat"
:key="index"
:width="$options.loader.width"
:height="$options.loader.height"
preserve-aspect-ratio="xMinYMax meet"
>
<rect width="15" x="0" y="12.5" height="15" rx="4" />
<rect width="250" x="25" y="10" height="20" rx="4" />
<circle cx="290" cy="20" r="10" />
<rect width="100" x="315" y="10" height="20" rx="4" />
<rect width="100" x="500" y="10" height="20" rx="4" />
<rect width="100" x="630" y="10" height="20" rx="4" />
<rect x="960" y="0" width="40" height="40" rx="4" />
</gl-skeleton-loader>
</div>
</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>
......@@ -125,7 +125,7 @@ export default {
:disabled="disabledDelete"
:title="$options.i18n.REMOVE_REPOSITORY_LABEL"
:aria-label="$options.i18n.REMOVE_REPOSITORY_LABEL"
class="btn-inverted"
category="secondary"
variant="danger"
icon="remove"
@click="$emit('delete', item)"
......
export const tags = state => {
// to show the loader inside the table we need to pass an empty array to gl-table whenever the table is loading
// this is to take in account isLoading = true and state.tags =[1,2,3] during pagination and delete
return state.isLoading ? [] : state.tags;
};
export const dockerBuildCommand = state => {
/* eslint-disable @gitlab/require-i18n-strings */
return `docker build -t ${state.config.repositoryUrl} .`;
......
......@@ -75,7 +75,7 @@ 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 }
click_on(class: 'js-delete-registry')
first('[data-testid="singleDeleteButton"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
......
......@@ -84,7 +84,7 @@ 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('.js-delete-registry').click
first('[data-testid="singleDeleteButton"]').click
expect(find('.modal .modal-title')).to have_content _('Remove tag')
find('.modal .modal-footer .btn-danger').click
end
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`TagsLoader component has the correct markup 1`] = `
<div>
<div
preserve-aspect-ratio="xMinYMax meet"
>
<rect
height="15"
rx="4"
width="15"
x="0"
y="12.5"
/>
<rect
height="20"
rx="4"
width="250"
x="25"
y="10"
/>
<circle
cx="290"
cy="20"
r="10"
/>
<rect
height="20"
rx="4"
width="100"
x="315"
y="10"
/>
<rect
height="20"
rx="4"
width="100"
x="500"
y="10"
/>
<rect
height="20"
rx="4"
width="100"
x="630"
y="10"
/>
<rect
height="40"
rx="4"
width="40"
x="960"
y="0"
/>
</div>
</div>
`;
......@@ -19,6 +19,11 @@ describe('Delete alert', () => {
wrapper = shallowMount(component, { stubs: { GlSprintf }, propsData });
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when deleteAlertType is null', () => {
it('does not show the alert', () => {
mountComponent();
......
......@@ -23,6 +23,11 @@ describe('Delete Modal', () => {
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains a GlModal', () => {
mountComponent();
expect(findModal().exists()).toBe(true);
......
......@@ -15,6 +15,11 @@ describe('Details Header', () => {
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('has the correct title ', () => {
mountComponent();
expect(wrapper.text()).toMatchInterpolatedText(DETAILS_PAGE_TITLE);
......
import { shallowMount } from '@vue/test-utils';
import { GlEmptyState } from '@gitlab/ui';
import component from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import {
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
} from '~/registry/explorer/constants';
describe('EmptyTagsState component', () => {
let wrapper;
const findEmptyState = () => wrapper.find(GlEmptyState);
const mountComponent = () => {
wrapper = shallowMount(component, {
stubs: {
GlEmptyState,
},
propsData: {
noContainersImage: 'foo',
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('contains gl-empty-state', () => {
mountComponent();
expect(findEmptyState().exist()).toBe(true);
});
it('has the correct props', () => {
mountComponent();
expect(findEmptyState().props()).toMatchObject({
title: EMPTY_IMAGE_REPOSITORY_TITLE,
description: EMPTY_IMAGE_REPOSITORY_MESSAGE,
svgPath: 'foo',
});
});
});
import { shallowMount } from '@vue/test-utils';
import component from '~/registry/explorer/components/details_page/tags_loader.vue';
import { GlSkeletonLoader } from '../../stubs';
describe('TagsLoader component', () => {
let wrapper;
const findGlSkeletonLoaders = () => wrapper.findAll(GlSkeletonLoader);
const mountComponent = () => {
wrapper = shallowMount(component, {
stubs: {
GlSkeletonLoader,
},
// set the repeat to 1 to avoid a long and verbose snapshot
loader: {
...component.loader,
repeat: 1,
},
});
};
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
it('produces the correct amount of loaders ', () => {
mountComponent();
expect(findGlSkeletonLoaders().length).toBe(1);
});
it('has the correct props', () => {
mountComponent();
expect(
findGlSkeletonLoaders()
.at(0)
.props(),
).toMatchObject({
width: component.loader.width,
height: component.loader.height,
});
});
it('has the correct markup', () => {
mountComponent();
expect(wrapper.element).toMatchSnapshot();
});
});
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('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems).toEqual([tags[0].name]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
});
});
it('if deselect remove name from selectedItems', () => {
wrapper.setData({ selectedItems: [tags[0].name] });
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);
});
});
});
});
......@@ -24,6 +24,11 @@ describe('Image List', () => {
mountComponent();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('list', () => {
it('contains one list element for each image', () => {
expect(findRow().length).toBe(imagesListResponse.data.length);
......
......@@ -2,35 +2,6 @@ import * as getters from '~/registry/explorer/stores/getters';
describe('Getters RegistryExplorer store', () => {
let state;
const tags = ['foo', 'bar'];
describe('tags', () => {
describe('when isLoading is false', () => {
beforeEach(() => {
state = {
tags,
isLoading: false,
};
});
it('returns tags', () => {
expect(getters.tags(state)).toEqual(state.tags);
});
});
describe('when isLoading is true', () => {
beforeEach(() => {
state = {
tags,
isLoading: true,
};
});
it('returns empty array', () => {
expect(getters.tags(state)).toEqual([]);
});
});
});
describe.each`
getter | prefix | configParameter | suffix
......
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 = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
methods: {
......@@ -14,3 +17,21 @@ export const RouterLink = {
template: `<div><slot></slot></div>`,
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: {
show: jest.fn(),
},
props: RealDeleteModal.props,
};
export const GlSkeletonLoader = {
template: `<div><slot></slot></div>`,
props: ['width', 'height'],
};
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