Commit 29a3c5a8 authored by Natalia Tepluhina's avatar Natalia Tepluhina

Merge branch '23315-add-vue-breadcrumb' into 'master'

Container Registry vue router  breadcrumb

See merge request gitlab-org/gitlab!23155
parents c3dec811 c270aaf7
......@@ -3,5 +3,7 @@ import registryExplorer from '~/registry/explorer/index';
document.addEventListener('DOMContentLoaded', () => {
initRegistryImages();
registryExplorer();
const { attachMainComponent, attachBreadcrumb } = registryExplorer();
attachBreadcrumb();
attachMainComponent();
});
......@@ -3,5 +3,7 @@ import registryExplorer from '~/registry/explorer/index';
document.addEventListener('DOMContentLoaded', () => {
initRegistryImages();
registryExplorer();
const { attachMainComponent, attachBreadcrumb } = registryExplorer();
attachBreadcrumb();
attachMainComponent();
});
<script>
import { initial, first, last } from 'lodash';
export default {
props: {
crumbs: {
type: Array,
required: true,
},
},
computed: {
rootRoute() {
return this.$router.options.routes.find(r => r.meta.root);
},
isRootRoute() {
return this.$route.name === this.rootRoute.name;
},
rootCrumbs() {
return initial(this.crumbs);
},
divider() {
const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg');
return { classList: [...classList], tagName, innerHTML };
},
lastCrumb() {
const { children } = last(this.crumbs);
const { tagName, classList } = first(children);
return {
tagName,
classList: [...classList],
text: this.$route.meta.nameGenerator(this.$route),
path: { to: this.$route.name },
};
},
},
};
</script>
<template>
<ul>
<li
v-for="(crumb, index) in rootCrumbs"
:key="index"
:class="crumb.classList"
v-html="crumb.innerHTML"
></li>
<li v-if="!isRootRoute">
<router-link ref="rootRouteLink" :to="rootRoute.path">
{{ rootRoute.meta.nameGenerator(rootRoute) }}
</router-link>
<component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" />
</li>
<li>
<component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.classList">
<router-link ref="childRouteLink" :to="lastCrumb.path">{{ lastCrumb.text }}</router-link>
</component>
</li>
</ul>
</template>
import Vue from 'vue';
import Translate from '~/vue_shared/translate';
import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores';
import createRouter from './router';
......@@ -19,15 +20,39 @@ export default () => {
const router = createRouter(endpoint, store);
store.dispatch('setInitialState', el.dataset);
return new Vue({
el,
store,
router,
components: {
RegistryExplorer,
},
render(createElement) {
return createElement('registry-explorer');
},
});
const attachMainComponent = () =>
new Vue({
el,
store,
router,
components: {
RegistryExplorer,
},
render(createElement) {
return createElement('registry-explorer');
},
});
const attachBreadcrumb = () => {
const breadCrumbEl = document.querySelector('nav .js-breadcrumbs-list');
const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')];
return new Vue({
el: breadCrumbEl,
store,
router,
components: {
RegistryBreadcrumb,
},
render(createElement) {
return createElement('registry-breadcrumb', {
class: breadCrumbEl.className,
props: {
crumbs,
},
});
},
});
};
return { attachBreadcrumb, attachMainComponent };
};
......@@ -19,6 +19,7 @@ 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 { decodeAndParse } from '../utils';
import {
LIST_KEY_TAG,
LIST_KEY_IMAGE_ID,
......@@ -62,7 +63,7 @@ export default {
computed: {
...mapState(['tags', 'tagsPagination', 'isLoading', 'config']),
imageName() {
const { name } = JSON.parse(window.atob(this.$route.params.id));
const { name } = decodeAndParse(this.$route.params.id);
return name;
},
fields() {
......@@ -169,7 +170,7 @@ export default {
},
handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = [];
this.requestDeleteTag({ tag: itemToDelete, imageId: this.$route.params.id });
this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id });
},
handleMultipleDelete() {
const { itemsToBeDeleted } = this;
......@@ -178,7 +179,7 @@ export default {
this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => this.tags[x].name),
imageId: this.$route.params.id,
params: this.$route.params.id,
});
},
onDeletionConfirmed() {
......
......@@ -70,7 +70,7 @@ export default {
this.itemToDelete = {};
},
encodeListItem(item) {
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path });
const params = JSON.stringify({ name: item.path, tags_path: item.tags_path, id: item.id });
return window.btoa(params);
},
},
......
import Vue from 'vue';
import VueRouter from 'vue-router';
import { __ } from '~/locale';
import { s__ } from '~/locale';
import List from './pages/list.vue';
import Details from './pages/details.vue';
import { decodeAndParse } from './utils';
Vue.use(VueRouter);
......@@ -16,7 +17,8 @@ export default function createRouter(base, store) {
path: '/',
component: List,
meta: {
name: __('Container Registry'),
nameGenerator: () => s__('ContainerRegistry|Container Registry'),
root: true,
},
beforeEnter: (to, from, next) => {
store.dispatch('requestImagesList');
......@@ -28,10 +30,10 @@ export default function createRouter(base, store) {
path: '/:id',
component: Details,
meta: {
name: __('Tags'),
nameGenerator: route => decodeAndParse(route.params.id).name,
},
beforeEnter: (to, from, next) => {
store.dispatch('requestTagsList', { id: to.params.id });
store.dispatch('requestTagsList', { params: to.params.id });
next();
},
},
......
......@@ -13,6 +13,7 @@ import {
DELETE_IMAGE_ERROR_MESSAGE,
DELETE_IMAGE_SUCCESS_MESSAGE,
} from '../constants';
import { decodeAndParse } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
......@@ -43,9 +44,9 @@ export const requestImagesList = ({ commit, dispatch, state }, pagination = {})
});
};
export const requestTagsList = ({ commit, dispatch }, { pagination = {}, id }) => {
export const requestTagsList = ({ commit, dispatch }, { pagination = {}, params }) => {
commit(types.SET_MAIN_LOADING, true);
const { tags_path } = JSON.parse(window.atob(id));
const { tags_path } = decodeAndParse(params);
const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios
......@@ -61,13 +62,13 @@ export const requestTagsList = ({ commit, dispatch }, { pagination = {}, id }) =
});
};
export const requestDeleteTag = ({ commit, dispatch, state }, { tag, imageId }) => {
export const requestDeleteTag = ({ commit, dispatch, state }, { tag, params }) => {
commit(types.SET_MAIN_LOADING, true);
return axios
.delete(tag.destroy_path)
.then(() => {
createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, id: imageId });
dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
.catch(() => {
createFlash(DELETE_TAG_ERROR_MESSAGE);
......@@ -77,15 +78,16 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, imageId })
});
};
export const requestDeleteTags = ({ commit, dispatch, state }, { ids, imageId }) => {
export const requestDeleteTags = ({ commit, dispatch, state }, { ids, params }) => {
commit(types.SET_MAIN_LOADING, true);
const url = `/${state.config.projectPath}/registry/repository/${imageId}/tags/bulk_destroy`;
const { id } = decodeAndParse(params);
const url = `/${state.config.projectPath}/registry/repository/${id}/tags/bulk_destroy`;
return axios
.delete(url, { params: { ids } })
.then(() => {
createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, id: imageId });
dispatch('requestTagsList', { pagination: state.tagsPagination, params });
})
.catch(() => {
createFlash(DELETE_TAGS_ERROR_MESSAGE);
......
// eslint-disable-next-line import/prefer-default-export
export const decodeAndParse = param => JSON.parse(window.atob(param));
// Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Breadcrumb when is rootRoute renders 1`] = `
<ul>
<li
class="foo bar"
>
baz
</li>
<li
class="foo bar"
>
foo
</li>
<!---->
<li>
<a
class="foo"
>
<a>
</a>
</a>
</li>
</ul>
`;
import { shallowMount } from '@vue/test-utils';
import component from '~/registry/explorer/components/registry_breadcrumb.vue';
describe('Registry Breadcrumb', () => {
let wrapper;
const nameGenerator = jest.fn();
const crumb = {
classList: ['foo', 'bar'],
tagName: 'div',
innerHTML: 'baz',
querySelector: jest.fn(),
children: [
{
tagName: 'a',
classList: ['foo'],
},
],
};
const querySelectorReturnValue = {
classList: ['js-divider'],
tagName: 'svg',
innerHTML: 'foo',
};
const crumbs = [crumb, { ...crumb, innerHTML: 'foo' }, { ...crumb, classList: ['baz'] }];
const routes = [
{ name: 'foo', meta: { nameGenerator, root: true } },
{ name: 'baz', meta: { nameGenerator } },
];
const findDivider = () => wrapper.find('.js-divider');
const findRootRoute = () => wrapper.find({ ref: 'rootRouteLink' });
const findChildRoute = () => wrapper.find({ ref: 'childRouteLink' });
const findLastCrumb = () => wrapper.find({ ref: 'lastCrumb' });
const mountComponent = $route => {
wrapper = shallowMount(component, {
propsData: {
crumbs,
},
stubs: {
'router-link': { name: 'router-link', template: '<a><slot></slot></a>', props: ['to'] },
},
mocks: {
$route,
$router: {
options: {
routes,
},
},
},
});
};
beforeEach(() => {
nameGenerator.mockClear();
crumb.querySelector = jest.fn();
});
afterEach(() => {
wrapper.destroy();
wrapper = null;
});
describe('when is rootRoute', () => {
beforeEach(() => {
mountComponent(routes[0]);
});
it('renders', () => {
expect(wrapper.element).toMatchSnapshot();
});
it('contains a router-link for the child route', () => {
expect(findChildRoute().exists()).toBe(true);
});
it('the link text is calculated by nameGenerator', () => {
expect(nameGenerator).toHaveBeenCalledWith(routes[0]);
expect(nameGenerator).toHaveBeenCalledTimes(1);
});
});
describe('when is not rootRoute', () => {
beforeEach(() => {
crumb.querySelector.mockReturnValue(querySelectorReturnValue);
mountComponent(routes[1]);
});
it('renders a divider', () => {
expect(findDivider().exists()).toBe(true);
});
it('contains a router-link for the root route', () => {
expect(findRootRoute().exists()).toBe(true);
});
it('contains a router-link for the child route', () => {
expect(findChildRoute().exists()).toBe(true);
});
it('the link text is calculated by nameGenerator', () => {
expect(nameGenerator).toHaveBeenCalledWith(routes[1]);
expect(nameGenerator).toHaveBeenCalledTimes(2);
});
});
describe('last crumb', () => {
const lastChildren = crumb.children[0];
beforeEach(() => {
nameGenerator.mockReturnValue('foo');
mountComponent(routes[0]);
});
it('has the same tag as the last children of the crumbs', () => {
expect(findLastCrumb().is(lastChildren.tagName)).toBe(true);
});
it('has the same classes as the last children of the crumbs', () => {
expect(findLastCrumb().classes()).toEqual(lastChildren.classList);
});
it('has a link to the current route', () => {
expect(findChildRoute().props('to')).toEqual({ to: routes[0].name });
});
it('the link has the correct text', () => {
expect(findChildRoute().text()).toEqual('foo');
});
});
});
......@@ -254,7 +254,7 @@ describe('Details Page', () => {
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0],
imageId: wrapper.vm.$route.params.id,
params: 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([]);
......@@ -271,7 +271,7 @@ describe('Details Page', () => {
return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name),
imageId: wrapper.vm.$route.params.id,
params: 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([]);
......
......@@ -121,14 +121,14 @@ describe('Actions RegistryExplorer Store', () => {
describe('fetch tags list', () => {
const url = `${endpoint}/1}`;
const path = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}` }));
const params = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}` }));
it('sets the tagsList', done => {
mock.onGet(url).replyOnce(200, registryServerResponse, {});
testAction(
actions.requestTagsList,
{ id: path },
{ params },
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
......@@ -147,7 +147,7 @@ describe('Actions RegistryExplorer Store', () => {
it('should create flash on error', done => {
testAction(
actions.requestTagsList,
{ id: path },
{ params },
{},
[
{ type: types.SET_MAIN_LOADING, payload: true },
......@@ -165,7 +165,7 @@ describe('Actions RegistryExplorer Store', () => {
describe('request delete single tag', () => {
it('successfully performs the delete request', done => {
const deletePath = 'delete/path';
const url = window.btoa(`${endpoint}/1}`);
const params = window.btoa(JSON.stringify({ tags_path: `${endpoint}/1}`, id: 1 }));
mock.onDelete(deletePath).replyOnce(200);
......@@ -175,7 +175,7 @@ describe('Actions RegistryExplorer Store', () => {
tag: {
destroy_path: deletePath,
},
imageId: url,
params,
},
{
tagsPagination: {},
......@@ -187,7 +187,7 @@ describe('Actions RegistryExplorer Store', () => {
[
{
type: 'requestTagsList',
payload: { pagination: {}, id: url },
payload: { pagination: {}, params },
},
],
() => {
......@@ -220,9 +220,10 @@ describe('Actions RegistryExplorer Store', () => {
});
describe('request delete multiple tags', () => {
const imageId = 1;
const id = 1;
const params = window.btoa(JSON.stringify({ id }));
const projectPath = 'project-path';
const url = `${projectPath}/registry/repository/${imageId}/tags/bulk_destroy`;
const url = `${projectPath}/registry/repository/${id}/tags/bulk_destroy`;
it('successfully performs the delete request', done => {
mock.onDelete(url).replyOnce(200);
......@@ -231,7 +232,7 @@ describe('Actions RegistryExplorer Store', () => {
actions.requestDeleteTags,
{
ids: [1, 2],
imageId,
params,
},
{
config: {
......@@ -246,7 +247,7 @@ describe('Actions RegistryExplorer Store', () => {
[
{
type: 'requestTagsList',
payload: { pagination: {}, id: 1 },
payload: { pagination: {}, params },
},
],
() => {
......@@ -263,7 +264,7 @@ describe('Actions RegistryExplorer Store', () => {
actions.requestDeleteTags,
{
ids: [1, 2],
imageId,
params,
},
{
config: {
......
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