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)"
......
<script>
import { mapState, mapActions, mapGetters } from 'vuex';
import {
GlTable,
GlFormCheckbox,
GlDeprecatedButton,
GlIcon,
GlTooltipDirective,
GlPagination,
GlEmptyState,
GlResizeObserverDirective,
GlSkeletonLoader,
} from '@gitlab/ui';
import { mapState, mapActions } from 'vuex';
import { GlPagination, GlResizeObserverDirective } from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
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 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 TagsLoader from '../components/details_page/tags_loader.vue';
import EmptyTagsState from '../components/details_page/empty_tags_state.vue';
import { decodeAndParse } from '../utils';
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,
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
ALERT_SUCCESS_TAG,
ALERT_DANGER_TAG,
ALERT_SUCCESS_TAGS,
......@@ -46,66 +22,29 @@ export default {
components: {
DeleteAlert,
DetailsHeader,
GlTable,
GlFormCheckbox,
GlDeprecatedButton,
GlIcon,
ClipboardButton,
GlPagination,
DeleteModal,
GlSkeletonLoader,
GlEmptyState,
TagsTable,
TagsLoader,
EmptyTagsState,
},
directives: {
GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [timeagoMixin, Tracking.mixin()],
loader: {
repeat: 10,
width: 1000,
height: 40,
},
i18n: {
REMOVE_TAGS_BUTTON_TITLE,
REMOVE_TAG_BUTTON_TITLE,
EMPTY_IMAGE_REPOSITORY_TITLE,
EMPTY_IMAGE_REPOSITORY_MESSAGE,
},
mixins: [Tracking.mixin()],
data() {
return {
selectedItems: [],
itemsToBeDeleted: [],
selectAllChecked: false,
modalDescription: null,
isDesktop: true,
deleteAlertType: null,
};
},
computed: {
...mapGetters(['tags']),
...mapState(['tagsPagination', 'isLoading', 'config']),
...mapState(['tagsPagination', 'isLoading', 'config', 'tags']),
imageName() {
const { name } = decodeAndParse(this.$route.params.id);
return name;
},
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);
},
tracking() {
return {
label:
......@@ -126,48 +65,8 @@ export default {
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
formatSize(size) {
return numberToHumanSize(size);
},
layers(layers) {
return layers ? n__('%d layer', '%d layers', layers) : '';
},
onSelectAllChange() {
if (this.selectAllChecked) {
this.deselectAll();
} else {
this.selectAll();
}
},
selectAll() {
this.selectedItems = this.tags.map(x => x.name);
this.selectAllChecked = true;
},
deselectAll() {
this.selectedItems = [];
this.selectAllChecked = false;
},
updateSelectedItems(name) {
const delIndex = this.selectedItems.findIndex(x => x === name);
if (delIndex > -1) {
this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false;
} else {
this.selectedItems.push(name);
if (this.selectedItems.length === this.tags.length) {
this.selectAllChecked = true;
}
}
},
deleteSingleItem(name) {
this.itemsToBeDeleted = [{ ...this.tags.find(t => t.name === name) }];
this.track('click_button');
this.$refs.deleteModal.show();
},
deleteMultipleItems() {
this.itemsToBeDeleted = this.selectedItems.map(name => ({
deleteTags(toBeDeletedList) {
this.itemsToBeDeleted = toBeDeletedList.map(name => ({
...this.tags.find(t => t.name === name),
}));
this.track('click_button');
......@@ -176,7 +75,6 @@ export default {
handleSingleDelete() {
const [itemToDelete] = this.itemsToBeDeleted;
this.itemsToBeDeleted = [];
this.selectedItems = this.selectedItems.filter(name => name !== itemToDelete.name);
return this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id })
.then(() => {
this.deleteAlertType = ALERT_SUCCESS_TAG;
......@@ -188,7 +86,6 @@ export default {
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
this.selectedItems = [];
return this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => x.name),
......@@ -227,116 +124,14 @@ export default {
<details-header :image-name="imageName" />
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop" show-empty>
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
ref="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<gl-deprecated-button
ref="bulkDeleteButton"
v-gl-tooltip
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
:title="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAGS_BUTTON_TITLE"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</template>
<template #cell(checkbox)="{item}">
<gl-form-checkbox
ref="rowCheckbox"
class="js-row-checkbox"
:checked="selectedItems.includes(item.name)"
@change="updateSelectedItems(item.name)"
/>
</template>
<template #cell(name)="{item, field}">
<div ref="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"
ref="rowClipboardButton"
:title="item.location"
:text="item.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
</template>
<template #cell(short_revision)="{value}">
<span ref="rowShortRevision">
{{ value }}
</span>
</template>
<template #cell(total_size)="{item}">
<span ref="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 ref="rowTime" v-gl-tooltip :title="tooltipTitle(value)">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{item}">
<gl-deprecated-button
ref="singleDeleteButton"
:title="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:aria-label="$options.i18n.REMOVE_TAG_BUTTON_TITLE"
:disabled="!item.destroy_path"
variant="danger"
class="js-delete-registry float-right btn-inverted btn-border-color btn-icon"
@click="deleteSingleItem(item.name)"
>
<gl-icon name="remove" />
</gl-deprecated-button>
</template>
<tags-table :tags="tags" :is-loading="isLoading" :is-desktop="isDesktop" @delete="deleteTags">
<template #empty>
<template v-if="isLoading">
<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>
<empty-tags-state :no-containers-image="config.noContainersImage" />
</template>
<gl-empty-state
v-else
:title="$options.i18n.EMPTY_IMAGE_REPOSITORY_TITLE"
:svg-path="config.noContainersImage"
:description="$options.i18n.EMPTY_IMAGE_REPOSITORY_MESSAGE"
class="mx-auto my-0"
/>
<template #loader>
<tags-loader v-once />
</template>
</gl-table>
</tags-table>
<gl-pagination
v-if="!isLoading"
......
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);
......
import { mount } from '@vue/test-utils';
import { GlTable, GlPagination, GlSkeletonLoader } from '@gitlab/ui';
import { shallowMount } from '@vue/test-utils';
import { GlPagination } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
import DeleteAlert from '~/registry/explorer/components/details_page/delete_alert.vue';
import DeleteModal from '~/registry/explorer/components/details_page/delete_modal.vue';
import DetailsHeader from '~/registry/explorer/components/details_page/details_header.vue';
import TagsLoader from '~/registry/explorer/components/details_page/tags_loader.vue';
import EmptyTagsState from '~/registry/explorer/components/details_page/empty_tags_state.vue';
import { createStore } from '~/registry/explorer/stores/';
import {
SET_MAIN_LOADING,
......@@ -15,7 +15,7 @@ import {
} from '~/registry/explorer/stores/mutation_types/';
import { tagsListResponse } from '../mock_data';
import { $toast } from '../../shared/mocks';
import { TagsTable, DeleteModal } from '../stubs';
describe('Details Page', () => {
let wrapper;
......@@ -24,28 +24,19 @@ describe('Details Page', () => {
const findDeleteModal = () => wrapper.find(DeleteModal);
const findPagination = () => wrapper.find(GlPagination);
const findSkeletonLoader = () => wrapper.find(GlSkeletonLoader);
const findMainCheckbox = () => wrapper.find({ ref: 'mainCheckbox' });
const findFirstRowItem = ref => wrapper.find({ ref });
const findBulkDeleteButton = () => wrapper.find({ ref: 'bulkDeleteButton' });
// findAll and refs seems to no work falling back to class
const findAllDeleteButtons = () => wrapper.findAll('.js-delete-registry');
const findAllCheckboxes = () => wrapper.findAll('.js-row-checkbox');
const findCheckedCheckboxes = () => findAllCheckboxes().filter(c => c.attributes('checked'));
const findFirsTagColumn = () => wrapper.find('.js-tag-column');
const findFirstTagNameText = () => wrapper.find('[data-testid="rowNameText"]');
const findTagsLoader = () => wrapper.find(TagsLoader);
const findTagsTable = () => wrapper.find(TagsTable);
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 mountComponent = options => {
wrapper = mount(component, {
wrapper = shallowMount(component, {
store,
stubs: {
...stubChildren(component),
GlSprintf: false,
GlTable,
TagsTable,
DeleteModal,
},
mocks: {
......@@ -54,7 +45,6 @@ describe('Details Page', () => {
id: routeId,
},
},
$toast,
},
...options,
});
......@@ -67,7 +57,6 @@ describe('Details Page', () => {
store.commit(SET_TAGS_LIST_SUCCESS, tagsListResponse.data);
store.commit(SET_TAGS_PAGINATION, tagsListResponse.headers);
jest.spyOn(Tracking, 'event');
jest.spyOn(DeleteModal.methods, 'show');
});
afterEach(() => {
......@@ -78,18 +67,14 @@ describe('Details Page', () => {
describe('when isLoading is true', () => {
beforeEach(() => {
mountComponent();
store.dispatch('receiveTagsListSuccess', { ...tagsListResponse, data: [] });
store.commit(SET_MAIN_LOADING, true);
return wrapper.vm.$nextTick();
});
afterAll(() => store.commit(SET_MAIN_LOADING, false));
afterEach(() => store.commit(SET_MAIN_LOADING, false));
it('has a skeleton loader', () => {
expect(findSkeletonLoader().exists()).toBe(true);
});
it('does not have list items', () => {
expect(findFirstRowItem('rowCheckbox').exists()).toBe(false);
it('binds isLoading to tags-table', () => {
expect(findTagsTable().props('isLoading')).toBe(true);
});
it('does not show pagination', () => {
......@@ -97,204 +82,76 @@ describe('Details Page', () => {
});
});
describe('table', () => {
it.each([
'rowCheckbox',
'rowName',
'rowShortRevision',
'rowSize',
'rowTime',
'singleDeleteButton',
])('%s exist in the table', element => {
mountComponent();
expect(findFirstRowItem(element).exists()).toBe(true);
});
describe('header checkbox', () => {
describe('table slots', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
expect(findMainCheckbox().exists()).toBe(true);
});
it('if selected set selectedItem and allSelected', () => {
findMainCheckbox().vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(findMainCheckbox().attributes('checked')).toBeTruthy();
expect(findCheckedCheckboxes()).toHaveLength(store.state.tags.length);
});
it('has the empty state', () => {
expect(findEmptyTagsState().exists()).toBe(true);
});
it('if deselect unset selectedItem and allSelected', () => {
wrapper.setData({ selectedItems: [1, 2], selectAllChecked: true });
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([store.state.tags[1].name]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
});
});
it('if deselect remove name from selectedItems', () => {
wrapper.setData({ selectedItems: [store.state.tags[1].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);
});
it('has a skeleton loader', () => {
expect(findTagsLoader().exists()).toBe(true);
});
});
describe('header delete button', () => {
describe('table', () => {
beforeEach(() => {
mountComponent();
});
it('exists', () => {
mountComponent();
expect(findBulkDeleteButton().exists()).toBe(true);
});
it('is disabled if no item is selected', () => {
mountComponent();
expect(findBulkDeleteButton().attributes('disabled')).toBe('true');
expect(findTagsTable().exists()).toBe(true);
});
it('is enabled if at least one item is selected', () => {
mountComponent({ data: () => ({ selectedItems: [store.state.tags[0].name] }) });
wrapper.setData({ selectedItems: [1] });
return wrapper.vm.$nextTick().then(() => {
expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy();
});
});
describe('on click', () => {
it('when one item is selected', () => {
mountComponent({ data: () => ({ selectedItems: [store.state.tags[0].name] }) });
jest.spyOn(wrapper.vm.$refs.deleteModal, 'show');
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]);
expect(DeleteModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
it('has the correct props bound', () => {
expect(findTagsTable().props()).toMatchObject({
isDesktop: true,
isLoading: false,
tags: store.state.tags,
});
it('when multiple items are selected', () => {
mountComponent({
data: () => ({ selectedItems: store.state.tags.map(t => t.name) }),
});
findBulkDeleteButton().vm.$emit('click');
expect(wrapper.vm.itemsToBeDeleted).toEqual(tagsListResponse.data);
expect(DeleteModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
});
});
});
});
describe('row delete button', () => {
describe('deleteEvent', () => {
describe('single item', () => {
beforeEach(() => {
mountComponent();
findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
});
it('exists', () => {
expect(
findAllDeleteButtons()
.at(0)
.exists(),
).toBe(true);
it('open the modal', () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('is disabled if the item has no destroy_path', () => {
expect(
findAllDeleteButtons()
.at(1)
.attributes('disabled'),
).toBe('true');
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual([store.state.tags[0]]);
});
it('on click', () => {
findAllDeleteButtons()
.at(0)
.vm.$emit('click');
expect(DeleteModal.methods.show).toHaveBeenCalled();
it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
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', () => {
describe('multiple items', () => {
beforeEach(() => {
mountComponent({
data() {
return { isDesktop: false };
},
});
findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
});
it('table header does not have class w-25', () => {
expect(findFirsTagColumn().classes()).not.toContain('w-25');
it('open the modal', () => {
expect(DeleteModal.methods.show).toHaveBeenCalled();
});
it('tag column has the gl-justify-content-end class', () => {
expect(findFirstRowItem('rowName').classes()).toContain('gl-justify-content-end');
});
it('maps the selection to itemToBeDeleted', () => {
expect(wrapper.vm.itemsToBeDeleted).toEqual(store.state.tags);
});
});
describe('last updated cell', () => {
let timeCell;
beforeEach(() => {
mountComponent();
timeCell = findFirstRowItem('rowTime');
it('tracks a single delete event', () => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
});
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');
});
});
});
......@@ -343,44 +200,33 @@ describe('Details Page', () => {
describe('confirmDelete event', () => {
describe('when one item is selected to be deleted', () => {
const itemsToBeDeleted = [{ name: 'foo' }];
beforeEach(() => {
mountComponent();
findTagsTable().vm.$emit('delete', [store.state.tags[0].name]);
});
it('dispatch requestDeleteTag with the right parameters', () => {
mountComponent({ data: () => ({ itemsToBeDeleted }) });
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTag', {
tag: itemsToBeDeleted[0],
tag: store.state.tags[0],
params: routeId,
});
});
it('remove the deleted item from the selected items', () => {
mountComponent({ data: () => ({ itemsToBeDeleted, selectedItems: ['foo', 'bar'] }) });
findDeleteModal().vm.$emit('confirmDelete');
expect(wrapper.vm.selectedItems).toEqual(['bar']);
});
});
describe('when more than one item is selected to be deleted', () => {
beforeEach(() => {
mountComponent({
data: () => ({
itemsToBeDeleted: [{ name: 'foo' }, { name: 'bar' }],
selectedItems: ['foo', 'bar'],
}),
});
mountComponent();
findTagsTable().vm.$emit('delete', store.state.tags.map(t => t.name));
});
it('dispatch requestDeleteTags with the right parameters', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(dispatchSpy).toHaveBeenCalledWith('requestDeleteTags', {
ids: ['foo', 'bar'],
ids: store.state.tags.map(t => t.name),
params: routeId,
});
});
it('clears the selectedItems', () => {
findDeleteModal().vm.$emit('confirmDelete');
expect(wrapper.vm.selectedItems).toEqual([]);
});
});
});
});
......
......@@ -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