Commit 3d39cbf5 authored by Nicolò Maria Mezzopera's avatar Nicolò Maria Mezzopera Committed by Natalia Tepluhina

Add content to details and list page

- list page
- details page
- project empty state
- group empty state

Add tracking info to list and details

- restore tracking on list
- restore tracking on details

Add basic test for list page

- stubs
- mocked data
- add refs for finders
- basic tests around delete functionality

Finalise list tests

- update list with refs and classes
- fix old slot syntax
- update mock_data
- more stubs

Add details page tests

- add refs to the page
- write unit tests
- fix mock_data

Adjust pot file

Lint test files and pot file update

- pot file
- remove unused eslint rule
- fix a direct assignment in test

Clean up tests by removing refs

- swap refs in list
- swap refs in details
- removed unused refs

Apply suggestion to  details_spec.js
Update gltable  according to new vue-boostrap
Refactor sprintf to gl-sprintf

- group empty state
- project empty state
- details
- list
- unit tests

Add missing component tests

Restore isGroupPage flag

- index file
- mutation
- unit test

Adjust pot file

Implement UX feedbacks

- remove back button
- add tags empty state
- remove row checkbox on mobile
- add tooltip to explain why delete btn is disabled
- adjust tests

Add image name to details page

- encode on list
- decode on details
- regenerate POT file

Update unit tests

- details_spec
- actions_spec
parent c8ace7cd
<script>
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import { mapState } from 'vuex';
export default {
name: 'GroupEmptyState',
components: {
GlEmptyState,
GlSprintf,
GlLink,
},
computed: {
...mapState(['config']),
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images available in this group')"
:svg-path="config.noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text">
<gl-sprintf
:message="
s__(
`ContainerRegistry|With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here. %{docLinkStart}More Information%{docLinkEnd}`,
)
"
>
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
</template>
</gl-empty-state>
</template>
<script>
import { GlEmptyState, GlSprintf, GlLink } from '@gitlab/ui';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { mapState } from 'vuex';
export default {
name: 'ProjectEmptyState',
components: {
ClipboardButton,
GlEmptyState,
GlSprintf,
GlLink,
},
computed: {
...mapState(['config']),
dockerBuildCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker build -t ${this.config.repositoryUrl} .`;
},
dockerPushCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker push ${this.config.repositoryUrl}`;
},
dockerLoginCommand() {
// eslint-disable-next-line @gitlab/i18n/no-non-i18n-strings
return `docker login ${this.config.registryHostUrlWithPort}`;
},
},
};
</script>
<template>
<gl-empty-state
:title="s__('ContainerRegistry|There are no container images stored for this project')"
:svg-path="config.noContainersImage"
class="container-message"
>
<template #description>
<p class="js-no-container-images-text">
<gl-sprintf
:message="
s__(`ContainerRegistry|With the Container Registry, every project can have its own space to
store its Docker images. %{docLinkStart}More Information%{docLinkEnd}`)
"
>
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">{{ content }}</gl-link>
</template>
</gl-sprintf>
</p>
<h5>{{ s__('ContainerRegistry|Quick Start') }}</h5>
<p class="js-not-logged-in-to-registry-text">
<gl-sprintf
:message="
s__(`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.`)
"
>
<template #twofaDocLink="{content}">
<gl-link :href="config.twoFactorAuthHelpLink" target="_blank">{{ content }}</gl-link>
</template>
<template #personalAccessTokensDocLink="{content}">
<gl-link :href="config.personalAccessTokensHelpLink" target="_blank">{{
content
}}</gl-link>
</template>
</gl-sprintf>
</p>
<div class="input-group append-bottom-10">
<input :value="dockerLoginCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerLoginCommand"
:title="s__('ContainerRegistry|Copy login command')"
class="input-group-text"
/>
</span>
</div>
<p></p>
<p>
{{
s__(
'ContainerRegistry|You can add an image to this registry with the following commands:',
)
}}
</p>
<div class="input-group append-bottom-10">
<input :value="dockerBuildCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerBuildCommand"
:title="s__('ContainerRegistry|Copy build command')"
class="input-group-text"
/>
</span>
</div>
<div class="input-group">
<input :value="dockerPushCommand" type="text" class="form-control monospace" readonly />
<span class="input-group-append">
<clipboard-button
:text="dockerPushCommand"
:title="s__('ContainerRegistry|Copy push command')"
class="input-group-text"
/>
</span>
</div>
</template>
</gl-empty-state>
</template>
<script>
export default {};
import { mapState, mapActions } from 'vuex';
import {
GlTable,
GlFormCheckbox,
GlButton,
GlIcon,
GlTooltipDirective,
GlPagination,
GlModal,
GlLoadingIcon,
GlSprintf,
GlEmptyState,
GlResizeObserverDirective,
} from '@gitlab/ui';
import { GlBreakpointInstance } from '@gitlab/ui/dist/utils';
import { n__, s__ } 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 {
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,
} from '../constants';
export default {
components: {
GlTable,
GlFormCheckbox,
GlButton,
GlIcon,
ClipboardButton,
GlPagination,
GlModal,
GlLoadingIcon,
GlSprintf,
GlEmptyState,
},
directives: {
GlTooltip: GlTooltipDirective,
GlResizeObserver: GlResizeObserverDirective,
},
mixins: [timeagoMixin, Tracking.mixin()],
data() {
return {
selectedItems: [],
itemsToBeDeleted: [],
selectAllChecked: false,
modalDescription: null,
isDesktop: true,
};
},
computed: {
...mapState(['tags', 'tagsPagination', 'isLoading', 'config']),
imageName() {
const { name } = JSON.parse(window.atob(this.$route.params.id));
return name;
},
fields() {
return [
{ key: LIST_KEY_CHECKBOX, label: '' },
{ key: LIST_KEY_TAG, label: LIST_LABEL_TAG },
{ 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);
},
isMultiDelete() {
return this.itemsToBeDeleted.length > 1;
},
tracking() {
return {
label: this.isMultiDelete ? 'bulk_registry_tag_delete' : 'registry_tag_delete',
};
},
modalAction() {
return n__(
'ContainerRegistry|Remove tag',
'ContainerRegistry|Remove tags',
this.isMultiDelete ? this.itemsToBeDeleted.length : 1,
);
},
currentPage: {
get() {
return this.tagsPagination.page;
},
set(page) {
this.requestTagsList({ pagination: { page }, id: this.$route.params.id });
},
},
},
methods: {
...mapActions(['requestTagsList', 'requestDeleteTag', 'requestDeleteTags']),
setModalDescription(itemIndex = -1) {
if (itemIndex === -1) {
this.modalDescription = {
message: s__(`ContainerRegistry|You are about to remove %{item} tags. Are you sure?`),
item: this.itemsToBeDeleted.length,
};
} else {
const { path } = this.tags[itemIndex];
this.modalDescription = {
message: s__(`ContainerRegistry|You are about to remove %{item}. Are you sure?`),
item: path,
};
}
},
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, index) => index);
this.selectAllChecked = true;
},
deselectAll() {
this.selectedItems = [];
this.selectAllChecked = false;
},
updateSelectedItems(index) {
const delIndex = this.selectedItems.findIndex(x => x === index);
if (delIndex > -1) {
this.selectedItems.splice(delIndex, 1);
this.selectAllChecked = false;
} else {
this.selectedItems.push(index);
if (this.selectedItems.length === this.tags.length) {
this.selectAllChecked = true;
}
}
},
deleteSingleItem(index) {
this.setModalDescription(index);
this.itemsToBeDeleted = [index];
this.track('click_button');
this.$refs.deleteModal.show();
},
deleteMultipleItems() {
this.itemsToBeDeleted = [...this.selectedItems];
if (this.selectedItems.length === 1) {
this.setModalDescription(this.itemsToBeDeleted[0]);
} else if (this.selectedItems.length > 1) {
this.setModalDescription();
}
this.track('click_button');
this.$refs.deleteModal.show();
},
handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = [];
this.requestDeleteTag({ tag: itemToDelete, imageId: this.$route.params.id });
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
this.itemsToBeDeleted = [];
this.selectedItems = [];
this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => this.tags[x].name),
imageId: this.$route.params.id,
});
},
onDeletionConfirmed() {
this.track('confirm_delete');
if (this.isMultiDelete) {
this.handleMultipleDelete();
} else {
const index = this.itemsToBeDeleted[0];
this.handleSingleDelete(this.tags[index]);
}
},
handleResize() {
this.isDesktop = GlBreakpointInstance.isDesktop();
},
},
};
</script>
<template>
<div></div>
<div
v-gl-resize-observer="handleResize"
class="my-3 position-absolute w-100 slide-enter-to-element"
>
<div class="d-flex my-3 align-items-center">
<h4>
<gl-sprintf :message="s__('ContainerRegistry|%{imageName} tags')">
<template #imageName>
{{ imageName }}
</template>
</gl-sprintf>
</h4>
</div>
<gl-loading-icon v-if="isLoading" />
<template v-else-if="tags.length > 0">
<gl-table :items="tags" :fields="fields" :stacked="!isDesktop">
<template v-if="isDesktop" #head(checkbox)>
<gl-form-checkbox
ref="mainCheckbox"
:checked="selectAllChecked"
@change="onSelectAllChange"
/>
</template>
<template #head(actions)>
<gl-button
ref="bulkDeleteButton"
v-gl-tooltip
:disabled="!selectedItems || selectedItems.length === 0"
class="float-right"
variant="danger"
:title="s__('ContainerRegistry|Remove selected tags')"
:aria-label="s__('ContainerRegistry|Remove selected tags')"
@click="deleteMultipleItems()"
>
<gl-icon name="remove" />
</gl-button>
</template>
<template #cell(checkbox)="{index}">
<gl-form-checkbox
ref="rowCheckbox"
class="js-row-checkbox"
:checked="selectedItems.includes(index)"
@change="updateSelectedItems(index)"
/>
</template>
<template #cell(name)="{item}">
<span ref="rowName">
{{ 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"
/>
</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">
{{ timeFormatted(value) }}
</span>
</template>
<template #cell(actions)="{index, item}">
<gl-button
ref="singleDeleteButton"
:title="s__('ContainerRegistry|Remove tag')"
:aria-label="s__('ContainerRegistry|Remove tag')"
:disabled="!item.destroy_path"
variant="danger"
:class="['js-delete-registry float-right btn-inverted btn-border-color btn-icon']"
@click="deleteSingleItem(index)"
>
<gl-icon name="remove" />
</gl-button>
</template>
</gl-table>
<gl-pagination
ref="pagination"
v-model="currentPage"
:per-page="tagsPagination.perPage"
:total-items="tagsPagination.total"
align="center"
class="w-100"
/>
<gl-modal
ref="deleteModal"
modal-id="delete-tag-modal"
ok-variant="danger"
@ok="onDeletionConfirmed"
@cancel="track('cancel_delete')"
>
<template #modal-title>{{ modalAction }}</template>
<template #modal-ok>{{ modalAction }}</template>
<p v-if="modalDescription">
<gl-sprintf :message="modalDescription.message">
<template #item>
<b>{{ modalDescription.item }}</b>
</template>
</gl-sprintf>
</p>
</gl-modal>
</template>
<gl-empty-state
v-else
:title="s__('ContainerRegistry|This image has no active tags')"
:svg-path="config.noContainersImage"
:description="
s__(
`ContainerRegistry|The last tag related to this image was recently removed.
This empty image and any associated data will be automatically removed as part of the regular Garbage Collection process.
If you have any questions, contact your administrator.`,
)
"
class="mx-auto my-0"
/>
</div>
</template>
<script>
export default {};
import { mapState, mapActions } from 'vuex';
import {
GlLoadingIcon,
GlEmptyState,
GlPagination,
GlTooltipDirective,
GlButton,
GlIcon,
GlModal,
GlSprintf,
GlLink,
} from '@gitlab/ui';
import Tracking from '~/tracking';
import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import ProjectEmptyState from '../components/project_empty_state.vue';
import GroupEmptyState from '../components/group_empty_state.vue';
export default {
name: 'RegistryListApp',
components: {
GlEmptyState,
GlLoadingIcon,
GlPagination,
ProjectEmptyState,
GroupEmptyState,
ClipboardButton,
GlButton,
GlIcon,
GlModal,
GlSprintf,
GlLink,
},
directives: {
GlTooltip: GlTooltipDirective,
},
mixins: [Tracking.mixin()],
data() {
return {
itemToDelete: {},
};
},
computed: {
...mapState(['config', 'isLoading', 'images', 'pagination']),
tracking() {
return {
label: 'registry_repository_delete',
};
},
currentPage: {
get() {
return this.pagination.page;
},
set(page) {
this.requestImagesList({ page });
},
},
},
methods: {
...mapActions(['requestImagesList', 'requestDeleteImage']),
deleteImage(item) {
// This event is already tracked in the system and so the name must be kept to aggregate the data
this.track('click_button');
this.itemToDelete = item;
this.$refs.deleteModal.show();
},
handleDeleteRepository() {
this.track('confirm_delete');
this.requestDeleteImage(this.itemToDelete.destroy_path);
this.itemToDelete = {};
},
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path });
return window.btoa(params);
},
},
};
</script>
<template>
<div></div>
<div class="position-absolute w-100 slide-enter-from-element">
<gl-empty-state
v-if="config.characterError"
:title="s__('ContainerRegistry|Docker connection error')"
:svg-path="config.containersErrorImage"
>
<template #description>
<p>
<gl-sprintf
:message="
s__(`ContainerRegistry|We are having trouble connecting to Docker, which could be due to an
issue with your project name or path.
%{docLinkStart}More Information%{docLinkEnd}`)
"
>
<template #docLink="{content}">
<gl-link :href="`${config.helpPagePath}#docker-connection-error`" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
</template>
</gl-empty-state>
<template v-else>
<gl-loading-icon v-if="isLoading" size="md" class="prepend-top-16" />
<template v-else>
<div v-if="images.length" ref="imagesList">
<h4>{{ s__('ContainerRegistry|Container Registry') }}</h4>
<p>
<gl-sprintf
:message="
s__(`ContainerRegistry|With the Docker Container Registry integrated into GitLab, every
project can have its own space to store its Docker images.
%{docLinkStart}More Information%{docLinkEnd}`)
"
>
<template #docLink="{content}">
<gl-link :href="config.helpPagePath" target="_blank">
{{ content }}
</gl-link>
</template>
</gl-sprintf>
</p>
<div class="d-flex flex-column">
<div
v-for="(listItem, index) in images"
:key="index"
ref="rowItem"
:class="[
'd-flex justify-content-between align-items-center py-2 border-bottom',
{ 'border-top': index === 0 },
]"
>
<div>
<router-link
ref="detailsLink"
:to="{ name: 'details', params: { id: encodeListItem(listItem) } }"
>
{{ listItem.path }}
</router-link>
<clipboard-button
v-if="listItem.location"
ref="clipboardButton"
:text="listItem.location"
:title="listItem.location"
css-class="btn-default btn-transparent btn-clipboard"
/>
</div>
<div
v-gl-tooltip="{ disabled: listItem.destroy_path }"
class="d-none d-sm-block"
:title="
s__(
'ContainerRegistry|Missing or insufficient permission, delete button disabled',
)
"
>
<gl-button
ref="deleteImageButton"
v-gl-tooltip
:disabled="!listItem.destroy_path"
:title="s__('ContainerRegistry|Remove repository')"
:aria-label="s__('ContainerRegistry|Remove repository')"
class="btn-inverted"
variant="danger"
@click="deleteImage(listItem)"
>
<gl-icon name="remove" />
</gl-button>
</div>
</div>
</div>
<gl-pagination
v-model="currentPage"
:per-page="pagination.perPage"
:total-items="pagination.total"
align="center"
class="w-100 mt-2"
/>
</div>
<template v-else>
<project-empty-state v-if="!config.isGroupPage" />
<group-empty-state v-else />
</template>
</template>
<gl-modal
ref="deleteModal"
modal-id="delete-image-modal"
ok-variant="danger"
@ok="handleDeleteRepository"
@cancel="track('cancel_delete')"
>
<template #modal-title>{{ s__('ContainerRegistry|Remove repository') }}</template>
<p>
<gl-sprintf
:message=" s__(
'ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted.',
),"
>
<template #title>
<b>{{ itemToDelete.path }}</b>
</template>
</gl-sprintf>
</p>
<template #modal-ok>{{ __('Remove') }}</template>
</gl-modal>
</template>
</div>
</template>
......@@ -45,11 +45,11 @@ export const requestImagesList = ({ commit, dispatch, state }, pagination = {})
export const requestTagsList = ({ commit, dispatch }, { pagination = {}, id }) => {
commit(types.SET_MAIN_LOADING, true);
const url = window.atob(id);
const { tags_path } = JSON.parse(window.atob(id));
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
.get(url, { params: { page, per_page: perPage } })
.get(tags_path, { params: { page, per_page: perPage } })
.then(({ data, headers }) => {
dispatch('receiveTagsListSuccess', { data, headers });
})
......
......@@ -5,6 +5,7 @@ export default {
[types.SET_INITIAL_STATE](state, config) {
state.config = {
...config,
isGroupPage: config.isGroupPage !== undefined,
};
},
......
......@@ -11,6 +11,7 @@
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"registry_host_url_with_port" => escape_once(registry_config.host_port),
is_group_page: true,
character_error: @character_error.to_s } }
- else
#js-vue-registry-images{ data: { endpoint: group_container_registries_path(@group, format: :json),
......
......@@ -5074,6 +5074,9 @@ msgstr ""
msgid "Container repositories sync capacity"
msgstr ""
msgid "ContainerRegistry|%{imageName} tags"
msgstr ""
msgid "ContainerRegistry|Automatically remove extra images that aren't designed to be kept."
msgstr ""
......@@ -5125,6 +5128,9 @@ msgstr ""
msgid "ContainerRegistry|Last Updated"
msgstr ""
msgid "ContainerRegistry|Missing or insufficient permission, delete button disabled"
msgstr ""
msgid "ContainerRegistry|Number of tags to retain:"
msgstr ""
......@@ -5190,12 +5196,21 @@ msgstr ""
msgid "ContainerRegistry|With the Docker Container Registry integrated into GitLab, every project can have its own space to store its Docker images. %{docLinkStart}More Information%{docLinkEnd}"
msgstr ""
msgid "ContainerRegistry|You are about to remove %{item} tags. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove %{item}. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove <b>%{count}</b> tags. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove <b>%{title}</b>. Are you sure?"
msgstr ""
msgid "ContainerRegistry|You are about to remove repository %{title}. Once you confirm, this repository will be permanently deleted."
msgstr ""
msgid "ContainerRegistry|You are about to remove repository <b>%{title}</b>. Once you confirm, this repository will be permanently deleted."
msgstr ""
......
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Group Empty state to match the default snapshot 1`] = `
<div
class="container-message"
svg-path="foo"
title="There are no container images available in this group"
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images. Push at least one Docker image in one of this group's projects in order to show up here.
<gl-link-stub
href="baz"
target="_blank"
>
More Information
</gl-link-stub>
</p>
</div>
`;
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Project Empty state to match the default snapshot 1`] = `
<div
class="container-message"
svg-path="bazFoo"
title="There are no container images stored for this project"
>
<p
class="js-no-container-images-text"
>
With the Container Registry, every project can have its own space to store its Docker images.
<gl-link-stub
href="baz"
target="_blank"
>
More Information
</gl-link-stub>
</p>
<h5>
Quick Start
</h5>
<p
class="js-not-logged-in-to-registry-text"
>
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
<gl-link-stub
href="barBaz"
target="_blank"
>
Two-Factor Authentication
</gl-link-stub>
enabled, use a
<gl-link-stub
href="fooBaz"
target="_blank"
>
Personal Access Token
</gl-link-stub>
instead of a password.
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<clipboard-button-stub
class="input-group-text"
cssclass="btn-default"
text="docker login bar"
title="Copy login command"
tooltipplacement="top"
/>
</span>
</div>
<p />
<p>
You can add an image to this registry with the following commands:
</p>
<div
class="input-group append-bottom-10"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<clipboard-button-stub
class="input-group-text"
cssclass="btn-default"
text="docker build -t foo ."
title="Copy build command"
tooltipplacement="top"
/>
</span>
</div>
<div
class="input-group"
>
<input
class="form-control monospace"
readonly="readonly"
type="text"
/>
<span
class="input-group-append"
>
<clipboard-button-stub
class="input-group-text"
cssclass="btn-default"
text="docker push foo"
title="Copy push command"
tooltipplacement="top"
/>
</span>
</div>
</div>
`;
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { GlEmptyState } from '../stubs';
import groupEmptyState from '~/registry/explorer/components/group_empty_state.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Registry Group Empty state', () => {
let wrapper;
let store;
beforeEach(() => {
store = new Vuex.Store({
state: {
config: {
noContainersImage: 'foo',
helpPagePath: 'baz',
},
},
});
wrapper = shallowMount(groupEmptyState, {
localVue,
store,
stubs: {
GlEmptyState,
GlSprintf,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
import Vuex from 'vuex';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlSprintf } from '@gitlab/ui';
import { GlEmptyState } from '../stubs';
import projectEmptyState from '~/registry/explorer/components/project_empty_state.vue';
const localVue = createLocalVue();
localVue.use(Vuex);
describe('Registry Project Empty state', () => {
let wrapper;
let store;
beforeEach(() => {
store = new Vuex.Store({
state: {
config: {
repositoryUrl: 'foo',
registryHostUrlWithPort: 'bar',
helpPagePath: 'baz',
twoFactorAuthHelpLink: 'barBaz',
personalAccessTokensHelpLink: 'fooBaz',
noContainersImage: 'bazFoo',
},
},
});
wrapper = shallowMount(projectEmptyState, {
localVue,
store,
stubs: {
GlEmptyState,
GlSprintf,
},
});
});
afterEach(() => {
wrapper.destroy();
});
it('to match the default snapshot', () => {
expect(wrapper.element).toMatchSnapshot();
});
});
export const headers = {
'X-PER-PAGE': 5,
'X-PAGE': 1,
'X-TOTAL': 13,
'X-TOTAL_PAGES': 1,
'X-NEXT-PAGE': null,
'X-PREVIOUS-PAGE': null,
};
export const reposServerResponse = [
{
destroy_path: 'path',
......@@ -36,3 +44,46 @@ export const registryServerResponse = [
created_at: 1505828744434,
},
];
export const imagesListResponse = {
data: [
{
path: 'foo',
location: 'location',
destroy_path: 'path',
},
{
path: 'bar',
location: 'location-2',
destroy_path: 'path-2',
},
],
headers,
};
export const tagsListResponse = {
data: [
{
tag: 'centos6',
revision: 'b118ab5b0e90b7cb5127db31d5321ac14961d097516a8e0e72084b6cdc783b43',
short_revision: 'b118ab5b0',
size: 19,
layers: 10,
location: 'location',
path: 'bar',
created_at: 1505828744434,
destroy_path: 'path',
},
{
tag: 'test-image',
revision: 'b969de599faea2b3d9b6605a8b0897261c571acaa36db1bdc7349b5775b4e0b4',
short_revision: 'b969de599',
size: 19,
layers: 10,
path: 'foo',
location: 'location-2',
created_at: 1505828744434,
},
],
headers,
};
import { mount } from '@vue/test-utils';
import { GlTable, GlPagination, GlLoadingIcon } from '@gitlab/ui';
import Tracking from '~/tracking';
import stubChildren from 'helpers/stub_children';
import component from '~/registry/explorer/pages/details.vue';
import store from '~/registry/explorer/stores/';
import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
import { tagsListResponse } from '../mock_data';
import { GlModal } from '../stubs';
describe('Details Page', () => {
let wrapper;
let dispatchSpy;
const findDeleteModal = () => wrapper.find(GlModal);
const findPagination = () => wrapper.find(GlPagination);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findTagsTable = () => wrapper.find(GlTable);
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 routeId = window.btoa(JSON.stringify({ name: 'foo', tags_path: 'bar' }));
beforeEach(() => {
wrapper = mount(component, {
store,
stubs: {
...stubChildren(component),
GlModal,
GlSprintf: false,
GlTable: false,
},
mocks: {
$route: {
params: {
id: routeId,
},
},
},
});
dispatchSpy = jest.spyOn(store, 'dispatch');
store.dispatch('receiveTagsListSuccess', tagsListResponse);
jest.spyOn(Tracking, 'event');
});
afterEach(() => {
wrapper.destroy();
});
describe('when isLoading is true', () => {
beforeAll(() => store.commit(SET_MAIN_LOADING, true));
afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('has a loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('does not have a main content', () => {
expect(findTagsTable().exists()).toBe(false);
expect(findPagination().exists()).toBe(false);
expect(findDeleteModal().exists()).toBe(false);
});
});
describe('table', () => {
it.each([
'rowCheckbox',
'rowName',
'rowShortRevision',
'rowSize',
'rowTime',
'singleDeleteButton',
])('%s exist in the table', element => {
expect(findFirstRowItem(element).exists()).toBe(true);
});
describe('header checkbox', () => {
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('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', () => {
it('if selected adds item to selectedItems', () => {
findFirstRowItem('rowCheckbox').vm.$emit('change');
return wrapper.vm.$nextTick().then(() => {
expect(wrapper.vm.selectedItems).toEqual([1]);
expect(findFirstRowItem('rowCheckbox').attributes('checked')).toBeTruthy();
});
});
it('if deselect remove index from selectedItems', () => {
wrapper.setData({ selectedItems: [1] });
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', () => {
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', () => {
wrapper.setData({ selectedItems: [1] });
return wrapper.vm.$nextTick().then(() => {
expect(findBulkDeleteButton().attributes('disabled')).toBeFalsy();
});
});
describe('on click', () => {
it('when one item is selected', () => {
wrapper.setData({ selectedItems: [1] });
findBulkDeleteButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain(
'You are about to remove <b>foo</b>. Are you sure?',
);
expect(GlModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
it('when multiple items are selected', () => {
wrapper.setData({ selectedItems: [0, 1] });
findBulkDeleteButton().vm.$emit('click');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain(
'You are about to remove <b>2</b> tags. Are you sure?',
);
expect(GlModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'bulk_registry_tag_delete',
});
});
});
});
});
describe('row delete button', () => {
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');
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain(
'You are about to remove <b>bar</b>. Are you sure?',
);
expect(GlModal.methods.show).toHaveBeenCalled();
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'click_button', {
label: 'registry_tag_delete',
});
});
});
});
});
describe('pagination', () => {
it('exists', () => {
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(store.state.tagsPagination.perPage);
expect(pagination.props('totalItems')).toBe(store.state.tagsPagination.total);
expect(pagination.props('value')).toBe(store.state.tagsPagination.page);
});
it('fetch the data from the API when the v-model changes', () => {
dispatchSpy.mockResolvedValue();
wrapper.setData({ currentPage: 2 });
expect(store.dispatch).toHaveBeenCalledWith('requestTagsList', {
id: wrapper.vm.$route.params.id,
pagination: { page: 2 },
});
});
});
describe('modal', () => {
it('exists', () => {
expect(findDeleteModal().exists()).toBe(true);
});
describe('when ok event is emitted', () => {
beforeEach(() => {
dispatchSpy.mockResolvedValue();
});
it('tracks confirm_delete', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'confirm_delete', {
label: 'registry_tag_delete',
});
});
});
it('when only one element is selected', () => {
const deleteModal = findDeleteModal();
wrapper.setData({ itemsToBeDeleted: [0] });
deleteModal.vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
imageId: wrapper.vm.$route.params.id,
});
// itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
it('when multiple elements are selected', () => {
const deleteModal = findDeleteModal();
wrapper.setData({ itemsToBeDeleted: [0, 1] });
deleteModal.vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
imageId: wrapper.vm.$route.params.id,
});
// itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
expect(findCheckedCheckboxes()).toHaveLength(0);
});
});
});
it('tracks cancel_delete when cancel event is emitted', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
return wrapper.vm.$nextTick().then(() => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, 'cancel_delete', {
label: 'registry_tag_delete',
});
});
});
});
});
import VueRouter from 'vue-router';
import { shallowMount, createLocalVue } from '@vue/test-utils';
import { GlPagination, GlLoadingIcon, GlSprintf } from '@gitlab/ui';
import Tracking from '~/tracking';
import component from '~/registry/explorer/pages/list.vue';
import store from '~/registry/explorer/stores/';
import { SET_MAIN_LOADING } from '~/registry/explorer/stores/mutation_types/';
import { imagesListResponse } from '../mock_data';
import { GlModal, GlEmptyState } from '../stubs';
const localVue = createLocalVue();
localVue.use(VueRouter);
describe('List Page', () => {
let wrapper;
let dispatchSpy;
const findDeleteBtn = () => wrapper.find({ ref: 'deleteImageButton' });
const findDeleteModal = () => wrapper.find(GlModal);
const findLoadingIcon = () => wrapper.find(GlLoadingIcon);
const findImagesList = () => wrapper.find({ ref: 'imagesList' });
const findRowItems = () => wrapper.findAll({ ref: 'rowItem' });
const findEmptyState = () => wrapper.find(GlEmptyState);
const findDetailsLink = () => wrapper.find({ ref: 'detailsLink' });
const findClipboardButton = () => wrapper.find({ ref: 'clipboardButton' });
const findPagination = () => wrapper.find(GlPagination);
beforeEach(() => {
wrapper = shallowMount(component, {
localVue,
store,
stubs: {
GlModal,
GlEmptyState,
GlSprintf,
},
});
dispatchSpy = jest.spyOn(store, 'dispatch');
store.dispatch('receiveImagesListSuccess', imagesListResponse);
});
afterEach(() => {
wrapper.destroy();
});
describe('connection error', () => {
const config = {
characterError: true,
containersErrorImage: 'foo',
helpPagePath: 'bar',
};
beforeAll(() => {
store.dispatch('setInitialState', config);
});
afterAll(() => {
store.dispatch('setInitialState', {});
});
it('should show an empty state', () => {
expect(findEmptyState().exists()).toBe(true);
});
it('empty state should have an svg-path', () => {
expect(findEmptyState().attributes('svg-path')).toBe(config.containersErrorImage);
});
it('empty state should have a description', () => {
expect(findEmptyState().html()).toContain('connection error');
});
it('should not show the loading or default state', () => {
expect(findLoadingIcon().exists()).toBe(false);
expect(findImagesList().exists()).toBe(false);
});
});
describe('when isLoading is true', () => {
beforeAll(() => store.commit(SET_MAIN_LOADING, true));
afterAll(() => store.commit(SET_MAIN_LOADING, false));
it('shows the loading icon', () => {
expect(findLoadingIcon().exists()).toBe(true);
});
it('imagesList is not visible', () => {
expect(findImagesList().exists()).toBe(false);
});
});
describe('list', () => {
describe('listElement', () => {
let listElements;
let firstElement;
beforeEach(() => {
listElements = findRowItems();
[firstElement] = store.state.images;
});
it('contains one list element for each image', () => {
expect(listElements.length).toBe(store.state.images.length);
});
it('contains a link to the details page', () => {
const link = findDetailsLink();
expect(link.html()).toContain(firstElement.path);
expect(link.props('to').name).toBe('details');
});
it('contains a clipboard button', () => {
const button = findClipboardButton();
expect(button.exists()).toBe(true);
expect(button.props('text')).toBe(firstElement.location);
expect(button.props('title')).toBe(firstElement.location);
});
describe('delete image', () => {
it('should be possible to delete a repo', () => {
const deleteBtn = findDeleteBtn();
expect(deleteBtn.exists()).toBe(true);
});
it('should call deleteItem when confirming deletion', () => {
dispatchSpy.mockResolvedValue();
const itemToDelete = wrapper.vm.images[0];
wrapper.setData({ itemToDelete });
findDeleteModal().vm.$emit('ok');
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith(
'requestDeleteImage',
itemToDelete.destroy_path,
);
});
});
});
describe('pagination', () => {
it('exists', () => {
expect(findPagination().exists()).toBe(true);
});
it('is wired to the correct pagination props', () => {
const pagination = findPagination();
expect(pagination.props('perPage')).toBe(store.state.pagination.perPage);
expect(pagination.props('totalItems')).toBe(store.state.pagination.total);
expect(pagination.props('value')).toBe(store.state.pagination.page);
});
it('fetch the data from the API when the v-model changes', () => {
dispatchSpy.mockReturnValue();
wrapper.setData({ currentPage: 2 });
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestImagesList', { page: 2 });
});
});
});
});
describe('modal', () => {
it('exists', () => {
expect(findDeleteModal().exists()).toBe(true);
});
it('contains a description with the path of the item to delete', () => {
wrapper.setData({ itemToDelete: { path: 'foo' } });
return wrapper.vm.$nextTick().then(() => {
expect(findDeleteModal().html()).toContain('foo');
});
});
});
describe('tracking', () => {
const testTrackingCall = action => {
expect(Tracking.event).toHaveBeenCalledWith(undefined, action, {
label: 'registry_repository_delete',
});
};
beforeEach(() => {
jest.spyOn(Tracking, 'event');
dispatchSpy.mockReturnValue();
});
it('send an event when delete button is clicked', () => {
const deleteBtn = findDeleteBtn();
deleteBtn.vm.$emit('click');
testTrackingCall('click_button');
});
it('send an event when cancel is pressed on modal', () => {
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('cancel');
testTrackingCall('cancel_delete');
});
it('send an event when confirm is clicked on modal', () => {
dispatchSpy.mockReturnValue();
const deleteModal = findDeleteModal();
deleteModal.vm.$emit('ok');
testTrackingCall('confirm_delete');
});
});
});
});
......@@ -120,14 +120,15 @@ describe('Actions RegistryExplorer Store', () => {
});
describe('fetch tags list', () => {
const url = window.btoa(`${endpoint}/1}`);
const url = `${endpoint}/1}`;
const path = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}` }));
it('sets the tagsList', done => {
mock.onGet(window.atob(url)).replyOnce(200, registryServerResponse, {});
mock.onGet(url).replyOnce(200, registryServerResponse, {});
testAction(
actions.requestTagsList,
{ id: url },
{ id: path },
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
......@@ -146,7 +147,7 @@ describe('Actions RegistryExplorer Store', () => {
it('should create flash on error', done => {
testAction(
actions.requestTagsList,
{ id: url },
{ id: path },
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
......
......@@ -10,8 +10,9 @@ describe('Mutations Registry Explorer Store', () => {
describe('SET_INITIAL_STATE', () => {
it('should set the initial state', () => {
const expectedState = { ...mockState, config: { endpoint: 'foo' } };
mutations[types.SET_INITIAL_STATE](mockState, { endpoint: 'foo' });
const payload = { endpoint: 'foo', isGroupPage: true };
const expectedState = { ...mockState, config: payload };
mutations[types.SET_INITIAL_STATE](mockState, payload);
expect(mockState).toEqual(expectedState);
});
......
export const GlModal = {
template: '<div><slot name="modal-title"></slot><slot></slot><slot name="modal-ok"></slot></div>',
methods: {
show: jest.fn(),
},
};
export const GlEmptyState = {
template: '<div><slot name="description"></slot></div>',
name: 'GlEmptyStateSTub',
};
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