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

Add vue powered breadcrumb

- component
- init function
- group page
- project page
parent f6b6dcf4
...@@ -3,5 +3,7 @@ import registryExplorer from '~/registry/explorer/index'; ...@@ -3,5 +3,7 @@ import registryExplorer from '~/registry/explorer/index';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initRegistryImages(); initRegistryImages();
registryExplorer(); const { attachMainComponent, attachBreadcrumb } = registryExplorer();
attachBreadcrumb();
attachMainComponent();
}); });
...@@ -3,5 +3,7 @@ import registryExplorer from '~/registry/explorer/index'; ...@@ -3,5 +3,7 @@ import registryExplorer from '~/registry/explorer/index';
document.addEventListener('DOMContentLoaded', () => { document.addEventListener('DOMContentLoaded', () => {
initRegistryImages(); 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 Vue from 'vue';
import Translate from '~/vue_shared/translate'; import Translate from '~/vue_shared/translate';
import RegistryExplorer from './pages/index.vue'; import RegistryExplorer from './pages/index.vue';
import RegistryBreadcrumb from './components/registry_breadcrumb.vue';
import { createStore } from './stores'; import { createStore } from './stores';
import createRouter from './router'; import createRouter from './router';
...@@ -19,15 +20,39 @@ export default () => { ...@@ -19,15 +20,39 @@ export default () => {
const router = createRouter(endpoint, store); const router = createRouter(endpoint, store);
store.dispatch('setInitialState', el.dataset); store.dispatch('setInitialState', el.dataset);
return new Vue({ const attachMainComponent = () =>
el, new Vue({
store, el,
router, store,
components: { router,
RegistryExplorer, components: {
}, RegistryExplorer,
render(createElement) { },
return createElement('registry-explorer'); 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'; ...@@ -19,6 +19,7 @@ import ClipboardButton from '~/vue_shared/components/clipboard_button.vue';
import { numberToHumanSize } from '~/lib/utils/number_utils'; import { numberToHumanSize } from '~/lib/utils/number_utils';
import timeagoMixin from '~/vue_shared/mixins/timeago'; import timeagoMixin from '~/vue_shared/mixins/timeago';
import Tracking from '~/tracking'; import Tracking from '~/tracking';
import { decodeAndParse } from '../utils';
import { import {
LIST_KEY_TAG, LIST_KEY_TAG,
LIST_KEY_IMAGE_ID, LIST_KEY_IMAGE_ID,
...@@ -62,7 +63,7 @@ export default { ...@@ -62,7 +63,7 @@ export default {
computed: { computed: {
...mapState(['tags', 'tagsPagination', 'isLoading', 'config']), ...mapState(['tags', 'tagsPagination', 'isLoading', 'config']),
imageName() { imageName() {
const { name } = JSON.parse(window.atob(this.$route.params.id)); const { name } = decodeAndParse(this.$route.params.id);
return name; return name;
}, },
fields() { fields() {
...@@ -169,7 +170,7 @@ export default { ...@@ -169,7 +170,7 @@ export default {
}, },
handleSingleDelete(itemToDelete) { handleSingleDelete(itemToDelete) {
this.itemsToBeDeleted = []; this.itemsToBeDeleted = [];
this.requestDeleteTag({ tag: itemToDelete, imageId: this.$route.params.id }); this.requestDeleteTag({ tag: itemToDelete, params: this.$route.params.id });
}, },
handleMultipleDelete() { handleMultipleDelete() {
const { itemsToBeDeleted } = this; const { itemsToBeDeleted } = this;
...@@ -178,7 +179,7 @@ export default { ...@@ -178,7 +179,7 @@ export default {
this.requestDeleteTags({ this.requestDeleteTags({
ids: itemsToBeDeleted.map(x => this.tags[x].name), ids: itemsToBeDeleted.map(x => this.tags[x].name),
imageId: this.$route.params.id, params: this.$route.params.id,
}); });
}, },
onDeletionConfirmed() { onDeletionConfirmed() {
......
...@@ -70,7 +70,7 @@ export default { ...@@ -70,7 +70,7 @@ export default {
this.itemToDelete = {}; this.itemToDelete = {};
}, },
encodeListItem(item) { 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); return window.btoa(params);
}, },
}, },
......
import Vue from 'vue'; import Vue from 'vue';
import VueRouter from 'vue-router'; import VueRouter from 'vue-router';
import { __ } from '~/locale'; import { s__ } from '~/locale';
import List from './pages/list.vue'; import List from './pages/list.vue';
import Details from './pages/details.vue'; import Details from './pages/details.vue';
import { decodeAndParse } from './utils';
Vue.use(VueRouter); Vue.use(VueRouter);
...@@ -16,7 +17,8 @@ export default function createRouter(base, store) { ...@@ -16,7 +17,8 @@ export default function createRouter(base, store) {
path: '/', path: '/',
component: List, component: List,
meta: { meta: {
name: __('Container Registry'), nameGenerator: () => s__('ContainerRegistry|Container Registry'),
root: true,
}, },
beforeEnter: (to, from, next) => { beforeEnter: (to, from, next) => {
store.dispatch('requestImagesList'); store.dispatch('requestImagesList');
...@@ -28,10 +30,10 @@ export default function createRouter(base, store) { ...@@ -28,10 +30,10 @@ export default function createRouter(base, store) {
path: '/:id', path: '/:id',
component: Details, component: Details,
meta: { meta: {
name: __('Tags'), nameGenerator: route => decodeAndParse(route.params.id).name,
}, },
beforeEnter: (to, from, next) => { beforeEnter: (to, from, next) => {
store.dispatch('requestTagsList', { id: to.params.id }); store.dispatch('requestTagsList', { params: to.params.id });
next(); next();
}, },
}, },
......
...@@ -13,6 +13,7 @@ import { ...@@ -13,6 +13,7 @@ import {
DELETE_IMAGE_ERROR_MESSAGE, DELETE_IMAGE_ERROR_MESSAGE,
DELETE_IMAGE_SUCCESS_MESSAGE, DELETE_IMAGE_SUCCESS_MESSAGE,
} from '../constants'; } from '../constants';
import { decodeAndParse } from '../utils';
export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data); export const setInitialState = ({ commit }, data) => commit(types.SET_INITIAL_STATE, data);
...@@ -43,9 +44,9 @@ export const requestImagesList = ({ commit, dispatch, state }, pagination = {}) ...@@ -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); 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; const { page = DEFAULT_PAGE, perPage = DEFAULT_PAGE_SIZE } = pagination;
return axios return axios
...@@ -61,13 +62,13 @@ export const requestTagsList = ({ commit, dispatch }, { pagination = {}, id }) = ...@@ -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); commit(types.SET_MAIN_LOADING, true);
return axios return axios
.delete(tag.destroy_path) .delete(tag.destroy_path)
.then(() => { .then(() => {
createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success'); createFlash(DELETE_TAG_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, id: imageId }); dispatch('requestTagsList', { pagination: state.tagsPagination, params });
}) })
.catch(() => { .catch(() => {
createFlash(DELETE_TAG_ERROR_MESSAGE); createFlash(DELETE_TAG_ERROR_MESSAGE);
...@@ -77,15 +78,16 @@ export const requestDeleteTag = ({ commit, dispatch, state }, { tag, imageId }) ...@@ -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); 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 return axios
.delete(url, { params: { ids } }) .delete(url, { params: { ids } })
.then(() => { .then(() => {
createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success'); createFlash(DELETE_TAGS_SUCCESS_MESSAGE, 'success');
dispatch('requestTagsList', { pagination: state.tagsPagination, id: imageId }); dispatch('requestTagsList', { pagination: state.tagsPagination, params });
}) })
.catch(() => { .catch(() => {
createFlash(DELETE_TAGS_ERROR_MESSAGE); 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', () => { ...@@ -254,7 +254,7 @@ describe('Details Page', () => {
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTag', { expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTag', {
tag: store.state.tags[0], 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 // itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]); expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
...@@ -271,7 +271,7 @@ describe('Details Page', () => { ...@@ -271,7 +271,7 @@ describe('Details Page', () => {
return wrapper.vm.$nextTick().then(() => { return wrapper.vm.$nextTick().then(() => {
expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTags', { expect(store.dispatch).toHaveBeenCalledWith('requestDeleteTags', {
ids: store.state.tags.map(t => t.name), 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 // itemsToBeDeleted is not represented in the DOM, is used as parking variable between selected and deleted items
expect(wrapper.vm.itemsToBeDeleted).toEqual([]); expect(wrapper.vm.itemsToBeDeleted).toEqual([]);
......
...@@ -121,14 +121,14 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -121,14 +121,14 @@ describe('Actions RegistryExplorer Store', () => {
describe('fetch tags list', () => { describe('fetch tags list', () => {
const url = `${endpoint}/1}`; 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 => { it('sets the tagsList', done => {
mock.onGet(url).replyOnce(200, registryServerResponse, {}); mock.onGet(url).replyOnce(200, registryServerResponse, {});
testAction( testAction(
actions.requestTagsList, actions.requestTagsList,
{ id: path }, { params },
{}, {},
[ [
{ type: types.SET_MAIN_LOADING, payload: true }, { type: types.SET_MAIN_LOADING, payload: true },
...@@ -147,7 +147,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -147,7 +147,7 @@ describe('Actions RegistryExplorer Store', () => {
it('should create flash on error', done => { it('should create flash on error', done => {
testAction( testAction(
actions.requestTagsList, actions.requestTagsList,
{ id: path }, { params },
{}, {},
[ [
{ type: types.SET_MAIN_LOADING, payload: true }, { type: types.SET_MAIN_LOADING, payload: true },
...@@ -165,7 +165,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -165,7 +165,7 @@ describe('Actions RegistryExplorer Store', () => {
describe('request delete single tag', () => { describe('request delete single tag', () => {
it('successfully performs the delete request', done => { it('successfully performs the delete request', done => {
const deletePath = 'delete/path'; 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); mock.onDelete(deletePath).replyOnce(200);
...@@ -175,7 +175,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -175,7 +175,7 @@ describe('Actions RegistryExplorer Store', () => {
tag: { tag: {
destroy_path: deletePath, destroy_path: deletePath,
}, },
imageId: url, params,
}, },
{ {
tagsPagination: {}, tagsPagination: {},
...@@ -187,7 +187,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -187,7 +187,7 @@ describe('Actions RegistryExplorer Store', () => {
[ [
{ {
type: 'requestTagsList', type: 'requestTagsList',
payload: { pagination: {}, id: url }, payload: { pagination: {}, params },
}, },
], ],
() => { () => {
...@@ -220,9 +220,10 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -220,9 +220,10 @@ describe('Actions RegistryExplorer Store', () => {
}); });
describe('request delete multiple tags', () => { describe('request delete multiple tags', () => {
const imageId = 1; const id = 1;
const params = window.btoa(JSON.stringify({ id }));
const projectPath = 'project-path'; 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 => { it('successfully performs the delete request', done => {
mock.onDelete(url).replyOnce(200); mock.onDelete(url).replyOnce(200);
...@@ -231,7 +232,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -231,7 +232,7 @@ describe('Actions RegistryExplorer Store', () => {
actions.requestDeleteTags, actions.requestDeleteTags,
{ {
ids: [1, 2], ids: [1, 2],
imageId, params,
}, },
{ {
config: { config: {
...@@ -246,7 +247,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -246,7 +247,7 @@ describe('Actions RegistryExplorer Store', () => {
[ [
{ {
type: 'requestTagsList', type: 'requestTagsList',
payload: { pagination: {}, id: 1 }, payload: { pagination: {}, params },
}, },
], ],
() => { () => {
...@@ -263,7 +264,7 @@ describe('Actions RegistryExplorer Store', () => { ...@@ -263,7 +264,7 @@ describe('Actions RegistryExplorer Store', () => {
actions.requestDeleteTags, actions.requestDeleteTags,
{ {
ids: [1, 2], ids: [1, 2],
imageId, params,
}, },
{ {
config: { 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