Commit 3801960f authored by Takuya Noguchi's avatar Takuya Noguchi

Fix UI on global breadcrumb on Project/Group Container Registry

This fix is done by partially replacing the existing pre JavaScript
code with gitlab-ui's Breadcrumb component. A small part of the fix
is a visual-only, transitional workaround, which can be removed in
MR 48115.

https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115Signed-off-by: default avatarTakuya Noguchi <takninnovationresearch@gmail.com>
parent 1371e8d5
<script> <script>
/* eslint-disable vue/no-v-html */ // We are using gl-breadcrumb only at the last child of the handwritten breadcrumb
// We are forced to use `v-html` untill this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079 // until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
// then we can re-write this to use gl-breadcrumb //
import { initial, first, last } from 'lodash'; // See the CSS workaround in app/assets/stylesheets/pages/registry.scss when this file is changed.
import { sanitize } from '~/lib/dompurify'; import { GlBreadcrumb, GlIcon } from '@gitlab/ui';
export default { export default {
props: { components: {
crumbs: { GlBreadcrumb,
type: Array, GlIcon,
required: true,
},
}, },
computed: { computed: {
parsedCrumbs() {
return this.crumbs.map((c) => ({ ...c, innerHTML: sanitize(c.innerHTML) }));
},
rootRoute() { rootRoute() {
return this.$router.options.routes.find((r) => r.meta.root); return this.$router.options.routes.find((r) => r.meta.root);
}, },
detailsRoute() {
return this.$router.options.routes.find((r) => r.name === 'details');
},
isRootRoute() { isRootRoute() {
return this.$route.name === this.rootRoute.name; return this.$route.name === this.rootRoute.name;
}, },
rootCrumbs() { isLoaded() {
return initial(this.parsedCrumbs); return this.isRootRoute || this.$store?.state.imageDetails?.name;
},
divider() {
const { classList, tagName, innerHTML } = first(this.crumbs).querySelector('svg');
return { classList: [...classList], tagName, innerHTML: sanitize(innerHTML) };
}, },
lastCrumb() { allCrumbs() {
const { children } = last(this.crumbs); const crumbs = [
const { tagName, className } = first(children); {
return { text: this.rootRoute.meta.nameGenerator(),
tagName, to: this.rootRoute.path,
className, },
text: this.$route.meta.nameGenerator(), ];
path: { to: this.$route.name }, if (!this.isRootRoute) {
}; crumbs.push({
text: this.detailsRoute.meta.nameGenerator(),
href: this.detailsRoute.meta.path,
});
}
return crumbs;
}, },
}, },
}; };
</script> </script>
<template> <template>
<ul> <gl-breadcrumb :key="isLoaded" :items="allCrumbs">
<li <template #separator>
v-for="(crumb, index) in rootCrumbs" <gl-icon name="angle-right" :size="8" />
:key="index" </template>
:class="crumb.className" </gl-breadcrumb>
v-html="crumb.innerHTML"
></li>
<li v-if="!isRootRoute">
<router-link ref="rootRouteLink" :to="rootRoute.path">
{{ rootRoute.meta.nameGenerator() }}
</router-link>
<component :is="divider.tagName" :class="divider.classList" v-html="divider.innerHTML" />
</li>
<li>
<component :is="lastCrumb.tagName" ref="lastCrumb" :class="lastCrumb.className">
<router-link ref="childRouteLink" :to="lastCrumb.path">{{ lastCrumb.text }}</router-link>
</component>
</li>
</ul>
</template> </template>
...@@ -71,16 +71,28 @@ export default () => { ...@@ -71,16 +71,28 @@ export default () => {
}); });
const attachBreadcrumb = () => { const attachBreadcrumb = () => {
const breadCrumbEl = document.querySelector('nav .js-breadcrumbs-list'); const breadCrumbEls = document.querySelectorAll('nav .js-breadcrumbs-list li');
const crumbs = [...document.querySelectorAll('.js-breadcrumbs-list li')]; const breadCrumbEl = breadCrumbEls[breadCrumbEls.length - 1];
const crumbs = [breadCrumbEl.querySelector('h2')];
const nestedBreadcrumbEl = document.createElement('div');
breadCrumbEl.replaceChild(nestedBreadcrumbEl, breadCrumbEl.querySelector('h2'));
return new Vue({ return new Vue({
el: breadCrumbEl, el: nestedBreadcrumbEl,
router, router,
apolloProvider, apolloProvider,
components: { components: {
RegistryBreadcrumb, RegistryBreadcrumb,
}, },
render(createElement) { render(createElement) {
// FIXME(@tnir): this is a workaround until the MR gets merged:
// https://gitlab.com/gitlab-org/gitlab/-/merge_requests/48115
const parentEl = breadCrumbEl.parentElement.parentElement;
if (parentEl) {
parentEl.classList.remove('breadcrumbs-container');
parentEl.classList.add('gl-display-flex');
parentEl.classList.add('w-100');
}
// End of FIXME(@tnir)
return createElement('registry-breadcrumb', { return createElement('registry-breadcrumb', {
class: breadCrumbEl.className, class: breadCrumbEl.className,
props: { props: {
......
...@@ -28,6 +28,7 @@ ...@@ -28,6 +28,7 @@
@import './pages/profiles/preferences'; @import './pages/profiles/preferences';
@import './pages/projects'; @import './pages/projects';
@import './pages/prometheus'; @import './pages/prometheus';
@import './pages/registry';
@import './pages/runners'; @import './pages/runners';
@import './pages/search'; @import './pages/search';
@import './pages/service_desk'; @import './pages/service_desk';
......
// Workaround for gl-breadcrumb at the last child of the handwritten breadcrumb
// until this gitlab-ui issue is resolved: https://gitlab.com/gitlab-org/gitlab-ui/-/issues/1079
//
// See app/assets/javascripts/registry/explorer/components/registry_breadcrumb.vue when this is changed.
.breadcrumbs-container .gl-breadcrumbs {
padding: 0;
box-shadow: none;
}
...@@ -17,10 +17,6 @@ ...@@ -17,10 +17,6 @@
} }
} }
.registry-placeholder {
min-height: 60px;
}
.auto-devops-card { .auto-devops-card {
margin-bottom: $gl-vert-padding; margin-bottom: $gl-vert-padding;
} }
...@@ -2,20 +2,18 @@ ...@@ -2,20 +2,18 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
%section %section
.row.registry-placeholder.prepend-bottom-10 #js-container-registry{ data: { endpoint: group_container_registries_path(@group),
.col-12 "help_page_path" => help_page_path('user/packages/container_registry/index'),
#js-container-registry{ data: { endpoint: group_container_registries_path(@group), "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"help_page_path" => help_page_path('user/packages/container_registry/index'), "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "registry_host_url_with_port" => escape_once(registry_config.host_port),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"registry_host_url_with_port" => escape_once(registry_config.host_port), "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), "is_admin": current_user&.admin.to_s,
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), is_group_page: "true",
"is_admin": current_user&.admin.to_s, "group_path": @group.full_path,
is_group_page: "true", "gid_prefix": container_repository_gid_prefix,
"group_path": @group.full_path, character_error: @character_error.to_s } }
"gid_prefix": container_repository_gid_prefix,
character_error: @character_error.to_s } }
...@@ -2,22 +2,20 @@ ...@@ -2,22 +2,20 @@
- @content_class = "limit-container-width" unless fluid_layout - @content_class = "limit-container-width" unless fluid_layout
%section %section
.row.registry-placeholder.prepend-bottom-10 #js-container-registry{ data: { endpoint: project_container_registry_index_path(@project),
.col-12 expiration_policy: @project.container_expiration_policy.to_json,
#js-container-registry{ data: { endpoint: project_container_registry_index_path(@project), "help_page_path" => help_page_path('user/packages/container_registry/index'),
expiration_policy: @project.container_expiration_policy.to_json, "two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'),
"help_page_path" => help_page_path('user/packages/container_registry/index'), "personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'),
"two_factor_auth_help_link" => help_page_path('user/profile/account/two_factor_authentication'), "no_containers_image" => image_path('illustrations/docker-empty-state.svg'),
"personal_access_tokens_help_link" => help_page_path('user/profile/personal_access_tokens'), "containers_error_image" => image_path('illustrations/docker-error-state.svg'),
"no_containers_image" => image_path('illustrations/docker-empty-state.svg'), "repository_url" => escape_once(@project.container_registry_url),
"containers_error_image" => image_path('illustrations/docker-error-state.svg'), "registry_host_url_with_port" => escape_once(registry_config.host_port),
"repository_url" => escape_once(@project.container_registry_url), "expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'),
"registry_host_url_with_port" => escape_once(registry_config.host_port), "garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'),
"expiration_policy_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'expiration-policy'), "run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'),
"garbage_collection_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'container-registry-garbage-collection'), "cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'),
"run_cleanup_policies_help_page_path" => help_page_path('administration/packages/container_registry', anchor: 'run-the-cleanup-policy-now'), "project_path": @project.full_path,
"cleanup_policies_help_page_path" => help_page_path('user/packages/container_registry/index', anchor: 'how-the-cleanup-policy-works'), "gid_prefix": container_repository_gid_prefix,
"project_path": @project.full_path, "is_admin": current_user&.admin.to_s,
"gid_prefix": container_repository_gid_prefix, character_error: @character_error.to_s } }
"is_admin": current_user&.admin.to_s,
character_error: @character_error.to_s } }
---
title: Fix UI on global breadcrumb on Project/Group Container Registry
merge_request: 48288
author: Takuya Noguchi
type: other
// Jest Snapshot v1, https://goo.gl/fbAQLP // Jest Snapshot v1, https://goo.gl/fbAQLP
exports[`Registry Breadcrumb when is rootRoute renders 1`] = ` exports[`Registry Breadcrumb when is not rootRoute renders 1`] = `
<ul> <div
<li class="gl-breadcrumbs"
class="foo bar" >
<ol
class="breadcrumb gl-breadcrumb-list"
> >
baz
</li> <li
<li class="breadcrumb-item gl-breadcrumb-item"
class="foo bar" >
<a
class=""
href="/"
target="_self"
/>
</li>
<span
class="gl-breadcrumb-separator"
data-testid="separator"
>
<svg
aria-hidden="true"
class="gl-icon s8"
data-testid="angle-right-icon"
>
<use
href="#angle-right"
/>
</svg>
</span>
<li
class="breadcrumb-item gl-breadcrumb-item"
>
<a
class=""
href="#"
target="_self"
/>
</li>
<!---->
</ol>
</div>
`;
exports[`Registry Breadcrumb when is rootRoute renders 1`] = `
<div
class="gl-breadcrumbs"
>
<ol
class="breadcrumb gl-breadcrumb-list"
> >
foo
</li> <li
class="breadcrumb-item gl-breadcrumb-item"
<!---->
<li>
<a
class="foo"
> >
<a> <a
class=""
</a> href="/"
</a> target="_self"
</li> />
</ul> </li>
<!---->
</ol>
</div>
`; `;
import { shallowMount } from '@vue/test-utils'; import { mount } from '@vue/test-utils';
import component from '~/registry/explorer/components/registry_breadcrumb.vue'; import component from '~/registry/explorer/components/registry_breadcrumb.vue';
...@@ -6,45 +6,13 @@ describe('Registry Breadcrumb', () => { ...@@ -6,45 +6,13 @@ describe('Registry Breadcrumb', () => {
let wrapper; let wrapper;
const nameGenerator = jest.fn(); const nameGenerator = jest.fn();
const crumb = {
className: 'foo bar',
tagName: 'div',
innerHTML: 'baz',
querySelector: jest.fn(),
children: [
{
tagName: 'a',
className: 'foo',
},
],
};
const querySelectorReturnValue = {
classList: ['js-divider'],
tagName: 'svg',
innerHTML: 'foo',
};
const crumbs = [crumb, { ...crumb, innerHTML: 'foo' }, { ...crumb, className: 'baz' }];
const routes = [ const routes = [
{ name: 'foo', meta: { nameGenerator, root: true } }, { name: 'list', path: '/', meta: { nameGenerator, root: true } },
{ name: 'baz', meta: { nameGenerator } }, { name: 'details', path: '/:id', 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) => { const mountComponent = ($route) => {
wrapper = shallowMount(component, { wrapper = mount(component, {
propsData: {
crumbs,
},
stubs: {
'router-link': { name: 'router-link', template: '<a><slot></slot></a>', props: ['to'] },
},
mocks: { mocks: {
$route, $route,
$router: { $router: {
...@@ -58,7 +26,6 @@ describe('Registry Breadcrumb', () => { ...@@ -58,7 +26,6 @@ describe('Registry Breadcrumb', () => {
beforeEach(() => { beforeEach(() => {
nameGenerator.mockClear(); nameGenerator.mockClear();
crumb.querySelector = jest.fn();
}); });
afterEach(() => { afterEach(() => {
...@@ -75,8 +42,11 @@ describe('Registry Breadcrumb', () => { ...@@ -75,8 +42,11 @@ describe('Registry Breadcrumb', () => {
expect(wrapper.element).toMatchSnapshot(); expect(wrapper.element).toMatchSnapshot();
}); });
it('contains a router-link for the child route', () => { it('contains only a single router-link to list', () => {
expect(findChildRoute().exists()).toBe(true); const links = wrapper.findAll('a');
expect(links).toHaveLength(1);
expect(links.at(0).attributes('href')).toBe('/');
}); });
it('the link text is calculated by nameGenerator', () => { it('the link text is calculated by nameGenerator', () => {
...@@ -86,48 +56,23 @@ describe('Registry Breadcrumb', () => { ...@@ -86,48 +56,23 @@ describe('Registry Breadcrumb', () => {
describe('when is not rootRoute', () => { describe('when is not rootRoute', () => {
beforeEach(() => { beforeEach(() => {
crumb.querySelector.mockReturnValue(querySelectorReturnValue);
mountComponent(routes[1]); mountComponent(routes[1]);
}); });
it('renders a divider', () => { it('renders', () => {
expect(findDivider().exists()).toBe(true); expect(wrapper.element).toMatchSnapshot();
}); });
it('contains a router-link for the root route', () => { it('contains two router-links to list and details', () => {
expect(findRootRoute().exists()).toBe(true); const links = wrapper.findAll('a');
});
it('contains a router-link for the child route', () => { expect(links).toHaveLength(2);
expect(findChildRoute().exists()).toBe(true); expect(links.at(0).attributes('href')).toBe('/');
expect(links.at(1).attributes('href')).toBe('#');
}); });
it('the link text is calculated by nameGenerator', () => { it('the link text is calculated by nameGenerator', () => {
expect(nameGenerator).toHaveBeenCalledTimes(2); 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().element.tagName).toBe(lastChildren.tagName.toUpperCase());
});
it('has the same classes as the last children of the crumbs', () => {
expect(findLastCrumb().classes().join(' ')).toEqual(lastChildren.className);
});
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');
});
});
}); });
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