Updating <router-link> to no longer use the deprecated :tag property

This commit is contained in:
Cody Jackson 2024-04-11 14:44:15 -07:00
parent 9cc17f1338
commit e205b568ee
6 changed files with 453 additions and 344 deletions

View File

@ -423,17 +423,24 @@ export default {
<!-- Cluster tools -->
<router-link
v-if="showClusterTools"
tag="div"
class="tools"
v-slot="{ href, navigate }"
custom
:to="{name: 'c-cluster-explorer-tools', params: {cluster: clusterId}}"
>
<a
class="tools-button"
@click="collapseAll()"
<div
class="tools"
@click="navigate"
@keypress.enter="navigate"
>
<i class="icon icon-gear" />
<span>{{ t('nav.clusterTools') }}</span>
</a>
<a
class="tools-button"
:href="href"
@click="collapseAll()"
>
<i class="icon icon-gear" />
<span>{{ t('nav.clusterTools') }}</span>
</a>
</div>
</router-link>
<!-- SideNav footer area (seems to be tied to harvester) -->
<div

View File

@ -1,94 +0,0 @@
<script>
export default {
name: 'TabbedLinks',
props: {
/**
* Default tab to display from the list
*/
defaultTab: {
type: String,
default: null,
required: false,
},
/**
* The list of tabs to display
* @model
*/
tabList: {
type: Array,
required: true,
default: () => [],
}
},
data() {
const { tabList } = this;
return { tabs: tabList };
},
};
</script>
<template>
<div>
<ul
v-if="tabs !== null && tabs.length > 0"
role="tablist"
class="tabs clearfix"
>
<router-link
v-for="tab in tabs"
:key="tab.name"
:to="tab.route"
tag="li"
role="presentation"
class="tab"
exact-active-class="active"
exact
>
<a>
{{ tab.label }}
</a>
</router-link>
</ul>
<div class="tab-container">
<slot />
</div>
</div>
</template>
<style lang="scss" scoped>
.tabs {
list-style-type: none;
margin: 0;
padding: 0;
.tab {
position: relative;
top: 1px;
float: left;
border-radius: 3px 3px 0 0;
margin: 0 8px 0 0;
A {
display: block;
padding: 10px 15px;
}
&:last-child {
margin-right: 0;
}
&.active {
background-color: var(--tabbed-container-bg);
border-bottom-color: var(--tabbed-container-bg);
}
}
}
.tab-container {
padding: 20px;
background-color: var(--tabbed-container-bg);
}
</style>

View File

@ -690,27 +690,48 @@ export default {
</li>
<router-link
v-if="showPreferencesLink"
tag="li"
v-slot="{ href, navigate }"
custom
:to="{name: 'prefs'}"
class="user-menu-item"
>
<a>{{ t('nav.userMenu.preferences') }}</a>
<li
class="user-menu-item"
@click="navigate"
@keypress.enter="navigate"
>
<a :href="href">{{ t('nav.userMenu.preferences') }}</a>
</li>
</router-link>
<router-link
v-if="showAccountAndApiKeyLink"
tag="li"
v-slot="{ href, navigate }"
custom
:to="{name: 'account'}"
class="user-menu-item"
>
<a>{{ t('nav.userMenu.accountAndKeys', {}, true) }}</a>
<li
class="user-menu-item"
@click="navigate"
@keypress.enter="navigate"
>
<a :href="href">{{ t('nav.userMenu.accountAndKeys', {}, true) }}</a>
</li>
</router-link>
<router-link
v-if="authEnabled"
tag="li"
v-slot="{ href, navigate }"
custom
:to="generateLogoutRoute"
class="user-menu-item"
>
<a @blur="showMenu(false)">{{ t('nav.userMenu.logOut') }}</a>
<li
class="user-menu-item"
@click="navigate"
@keypress.enter="navigate"
>
<a
:href="href"
@blur="showMenu(false)"
>{{ t('nav.userMenu.logOut') }}</a>
</li>
</router-link>
</ul>
</template>

View File

@ -28,65 +28,10 @@ export default {
},
data() {
return {
near: false,
over: false,
menuPath: this.type.route ? this.$router.resolve(this.type.route)?.route?.path : undefined,
};
return { near: false };
},
computed: {
isCurrent() {
// This is required to avoid scenarios where fragments break vue routers location matching
// For example, the following fails
// Curruent Path /c/c-m-hzqf4tqt/explorer/members#project-membership
// Menu Path /c/c-m-hzqf4tqt/explorer/members
// vue-router exact-path="true" fixes this (https://v3.router.vuejs.org/api/#exact-path),
// but fails when the the current path is a child (for instance a resource detail page)
// Scenarios to consider
// - Fragement world
// Curruent Path /c/c-m-hzqf4tqt/explorer/members#project-membership
// Menu Path /c/c-m-hzqf4tqt/explorer/members
// - Similar current paths
// /c/c-m-hzqf4tqt/fleet/fleet.cattle.io.bundlenamespacemapping
// /c/c-m-hzqf4tqt/fleet/fleet.cattle.io.bundle
// - Other menu items that appear in current menu item
// /c/c-m-hzqf4tqt/fleet
// /c/c-m-hzqf4tqt/fleet/management.cattle.io.fleetworkspace
// If there's no hash the n-link will determine it's linkActiveClass correctly, so avoid this faff
const invalidHash = !this.$route.hash;
// Lets be super safe
const invalidProps = !this.menuPath || !this.$route.path;
if (invalidHash || invalidProps) {
return false;
}
// We're kind of, but in a fixing way, copying n-link --> vue-router link see vue-router/src/components/link.js & vue-router/src/util/route.js
// We're only going to compare the path and ignore query and fragment
if (this.type.exact) {
return this.$route.path === this.menuPath;
}
const currentPath = this.$route.path.split('/');
const menuPath = this.menuPath.split('/');
if (menuPath.length > currentPath.length) {
return false;
}
for (let i = 0; i < menuPath.length; i++) {
if (menuPath[i] !== currentPath[i]) {
return false;
}
}
return true;
},
showFavorite() {
return ( this.type.mode && this.near && showFavoritesFor.includes(this.type.mode) );
},
@ -116,14 +61,6 @@ export default {
this.near = val;
},
setOver(val) {
this.over = val;
},
removeFavorite() {
this.$store.dispatch('type-map/removeFavorite', this.type.name);
},
selectType() {
// Prevent issues if custom NavLink is used #5047
if (this.type?.route) {
@ -142,63 +79,70 @@ export default {
<router-link
v-if="type.route"
:key="type.name"
v-slot="{ href, navigate, isActive, isExactActive }"
custom
:to="type.route"
tag="li"
class="child nav-type"
:class="{'root': isRoot, [`depth-${depth}`]: true, 'router-link-active': isCurrent}"
:exact="type.exact"
>
<TabTitle
v-if="$router.resolve(type.route).route.path === $route.path"
:show-child="false"
<li
class="child nav-type"
:class="{'root': isRoot, [`depth-${depth}`]: true, 'router-link-active': isActive, 'router-link-exact-active': isExactActive}"
@click="navigate"
@keypress.enter="navigate"
>
{{ type.labelKey ? t(type.labelKey) : (type.labelDisplay || type.label) }}
</TabTitle>
<a
@click="selectType"
@mouseenter="setNear(true)"
@mouseleave="setNear(false)"
>
<span
v-if="type.labelKey"
class="label"
><t :k="type.labelKey" /></span>
<span
v-else
v-clean-html="type.labelDisplay || type.label"
class="label"
:class="{'no-icon': !type.icon}"
/>
<span
v-if="showFavorite || namespaceIcon || showCount"
class="count"
<TabTitle
v-if="isExactActive"
:show-child="false"
>
<Favorite
v-if="showFavorite"
:resource="type.name"
/>
<i
v-if="namespaceIcon"
class="icon icon-namespace"
:class="{'ns-and-icon': showCount}"
{{ type.labelKey ? t(type.labelKey) : (type.labelDisplay || type.label) }}
</TabTitle>
<a
:href="href"
@click="selectType"
@mouseenter="setNear(true)"
@mouseleave="setNear(false)"
>
<span
v-if="type.labelKey"
class="label"
><t :k="type.labelKey" /></span>
<span
v-else
v-clean-html="type.labelDisplay || type.label"
class="label"
:class="{'no-icon': !type.icon}"
/>
<span
v-if="showCount"
>{{ count }}</span>
</span>
</a>
v-if="showFavorite || namespaceIcon || showCount"
class="count"
>
<Favorite
v-if="showFavorite"
:resource="type.name"
/>
<i
v-if="namespaceIcon"
class="icon icon-namespace"
:class="{'ns-and-icon': showCount}"
data-testid="type-namespaced"
/>
<span
v-if="showCount"
data-testid="type-count"
>{{ count }}</span>
</span>
</a>
</li>
</router-link>
<li
v-else-if="type.link"
class="child nav-type"
data-testid="link-type"
>
<a
:href="type.link"
:target="type.target"
rel="noopener noreferrer nofollow"
@click="selectType"
@mouseenter="setNear(true)"
@mouseleave="setNear(false)"
>
<span class="label">{{ type.label }}&nbsp;<i class="icon icon-external-link" /></span>
</a>

View File

@ -1,167 +1,362 @@
import { mount, RouterLinkStub } from '@vue/test-utils';
import { shallowMount, RouterLinkStub, createLocalVue } from '@vue/test-utils';
import Type from '@shell/components/nav/Type.vue';
import { createChildRenderingRouterLinkStub } from '@shell/utils/unit-tests/ChildRenderingRouterLinkStub';
import { TYPE_MODES } from '@shell/store/type-map';
// Mandatory to mock vue-router in this test
jest.mock('vue-router');
// Configuration text
const className = 'router-link-active';
// Configuration
const activeClass = 'router-link-active';
const exactActiveClass = 'router-link-exact-active';
const rootClass = 'root';
const favoriteClass = 'favorite';
describe('component: Type', () => {
describe('should not use highlight class', () => {
it('given no hash', () => {
const wrapper = mount(Type, {
propsData: { type: { route: 'something else' } },
stubs: { nLink: RouterLinkStub },
mocks: {
$route: { path: 'whatever' },
$router: { resolve: () => ({ route: { path: 'whatever' } }) },
$store: {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => 1,
}
}
},
describe('testing router-link type', () => {
const localVue = createLocalVue();
localVue.directive('cleanHtml', (identity) => identity);
const defaultRouteTypeProp = {
name: 'route-type',
route: 'route',
exact: true,
mode: TYPE_MODES.FAVORITE
};
const defaultCount = 1;
const storeMock = {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => defaultCount,
}
};
describe('should pass props correctly', () => {
it('should forward Type props to router-link', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: RouterLinkStub },
});
const linkStub = wrapper.findComponent(RouterLinkStub);
expect(linkStub.props().to).toBe(defaultRouteTypeProp.route);
expect(linkStub.props().exact).toBe(defaultRouteTypeProp.exact);
});
const highlight = wrapper.find(`.${ className }`);
it('should use router-link-slot href prop', () => {
const fakeHref = 'fake-href';
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub({ href: fakeHref }) },
mocks: { $store: storeMock }
});
expect(highlight.exists()).toBe(false);
});
const elementWithSelector = wrapper.find(`a[href='${ fakeHref }']`);
it('given no path', () => {
const wrapper = mount(Type, {
propsData: { type: { route: 'something else' } },
stubs: { nLink: RouterLinkStub },
mocks: {
$route: { hash: 'whatever' },
$router: { resolve: () => ({ route: { path: 'whatever' } }) },
$store: {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => 1,
}
}
},
expect(elementWithSelector.exists()).toBe(true);
});
const highlight = wrapper.find(`.${ className }`);
it('should use router-link-slot navigate prop', () => {
const navigate = jest.fn();
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub({ isActive: true, navigate }) },
mocks: { $store: storeMock }
});
expect(highlight.exists()).toBe(false);
const elementWithSelector = wrapper.find(`.${ activeClass }`);
elementWithSelector.trigger('click');
expect(navigate).toHaveBeenCalledTimes(1);
});
});
it('given no matching values', () => {
const wrapper = mount(Type, {
propsData: { type: {} },
stubs: { nLink: RouterLinkStub },
mocks: {
$route: {
hash: 'hash',
path: 'path',
describe('should not use classes if preconditions are not met', () => {
it('should not use active class if the link is not active', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub({ isActive: false }) },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.${ activeClass }`);
expect(elementWithSelector.exists()).toBe(false);
});
it('should not use exact active class if the link is not active', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub({ isExactActive: false }) },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.${ exactActiveClass }`);
expect(elementWithSelector.exists()).toBe(false);
});
it('should not use root class if the isRoot prop is false', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp, isRoot: false },
stubs: { routerLink: createChildRenderingRouterLinkStub() },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.${ rootClass }`);
expect(elementWithSelector.exists()).toBe(false);
});
});
describe('should use classes if preconditions are met', () => {
it('should use active class if the link is active', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub({ isActive: true }) },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.${ activeClass }`);
expect(elementWithSelector.exists()).toBe(true);
});
it('should use exact active class if the link is active', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub({ isExactActive: true }) },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.${ exactActiveClass }`);
expect(elementWithSelector.exists()).toBe(true);
});
it('should use root class if the isRoot prop is true', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp, isRoot: true },
stubs: { routerLink: createChildRenderingRouterLinkStub() },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.${ rootClass }`);
expect(elementWithSelector.exists()).toBe(true);
});
it('should show depth-0 class if depth prop is not defined', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: { routerLink: createChildRenderingRouterLinkStub() },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.depth-0`);
expect(elementWithSelector.exists()).toBe(true);
});
it('should show depth-1 class if depth prop is defined as 1', () => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp, depth: 1 },
stubs: { routerLink: createChildRenderingRouterLinkStub() },
mocks: { $store: storeMock }
});
const elementWithSelector = wrapper.find(`.depth-1`);
expect(elementWithSelector.exists()).toBe(true);
});
});
describe('should handle the favorite icon appropriately', () => {
it('should show favorite icon if mouse is over and type is favorite', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
$router: { resolve: () => ({ route: { path: 'whatever' } }) },
},
mocks: { $store: storeMock }
});
const aElement = wrapper.find(`a`);
aElement.trigger('mouseenter');
await wrapper.vm.$nextTick();
const favoriteElement = wrapper.find(`.${ favoriteClass }`);
expect(favoriteElement.exists()).toBe(true);
});
const highlight = wrapper.find(`.${ className }`);
it('should not show favorite icon if mouse is not over and type is favorite', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
mocks: { $store: storeMock }
});
expect(highlight.exists()).toBe(false);
const favoriteElement = wrapper.find(`.${ favoriteClass }`);
expect(favoriteElement.exists()).toBe(false);
});
it('should not show favorite icon if mouse is over and type is not favorite', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: { ...defaultRouteTypeProp, mode: null } },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
mocks: { $store: storeMock }
});
const aElement = wrapper.find(`a`);
aElement.trigger('mouseenter');
await wrapper.vm.$nextTick();
const favoriteElement = wrapper.find(`.${ favoriteClass }`);
expect(favoriteElement.exists()).toBe(false);
});
});
it('given navigation path is bigger than current page route path', () => {
const wrapper = mount(Type, {
propsData: { type: { route: 'not empty' } },
stubs: { nLink: RouterLinkStub },
mocks: {
$route: {
hash: 'not empty',
path: 'whatever',
describe('should handle count appropriately', () => {
it('should show count if on type', async() => {
const count = 2;
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: { ...defaultRouteTypeProp, count } },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
$router: { resolve: () => ({ route: { path: 'many/parts' } }) },
$store: {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => 1,
}
}
},
mocks: { $store: storeMock }
});
const typeCount = wrapper.find(`span[data-testid="type-count"]`);
expect(Number.parseInt(typeCount.text())).toBe(count);
});
const highlight = wrapper.find(`.${ className }`);
it('should show count if in store', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
mocks: { $store: storeMock }
});
expect(highlight.exists()).toBe(false);
const typeCount = wrapper.find(`span[data-testid="type-count"]`);
expect(Number.parseInt(typeCount.text())).toBe(defaultCount);
});
it('should not show count if not in type or store', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: defaultRouteTypeProp },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
mocks: {
$store: {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => null,
}
}
}
});
const typeCount = wrapper.find(`span[data-testid="type-count"]`);
expect(typeCount.exists()).toBe(false);
});
});
it.each([
// URL with fragments like anchors
[
'/c/c-m-hzqf4tqt/explorer/members#project-membership',
'/c/c-m-hzqf4tqt/explorer/members'
],
// Similar paths
[
'/c/c-m-hzqf4tqt/fleet/fleet.cattle.io.bundlenamespacemapping',
'/c/c-m-hzqf4tqt/fleet/fleet.cattle.io.bundle'
],
// paths with same parts, e.g. parents
[
'/c/c-m-hzqf4tqt/fleet',
'/c/c-m-hzqf4tqt/fleet/management.cattle.io.fleetworkspace'
],
])('given different current path %p and menu path %p', (currentPath, menuPath) => {
const wrapper = mount(Type, {
propsData: { type: { route: 'not empty' } },
stubs: { nLink: RouterLinkStub },
mocks: {
$route: {
hash: 'not empty',
path: currentPath,
describe('should handle namespace appropriately', () => {
it('should show namespace if on type', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: { ...defaultRouteTypeProp, namespaced: true } },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
$router: { resolve: () => ({ route: { path: menuPath } }) },
$store: {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => 1,
}
}
},
mocks: { $store: storeMock }
});
const namespaced = wrapper.find(`i[data-testid="type-namespaced"]`);
expect(namespaced.exists()).toBe(true);
});
const highlight = wrapper.find(`.${ className }`);
it('should not show namespace if not on type', async() => {
const wrapper = shallowMount(Type as any, {
localVue,
propsData: { type: { ...defaultRouteTypeProp, namespaced: false } },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
mocks: { $store: storeMock }
});
expect(highlight.exists()).toBe(false);
const namespaced = wrapper.find(`i[data-testid="type-namespaced"]`);
expect(namespaced.exists()).toBe(false);
});
});
});
describe('should use highlight class', () => {
it.each([
[
'same',
'same'
],
])('given same current path %p and menu path %p (on first load)', (currentPath, menuPath) => {
const wrapper = mount(Type, {
propsData: { type: { route: 'not empty' } },
stubs: { nLink: RouterLinkStub },
mocks: {
$route: {
hash: 'not empty',
path: currentPath,
},
$router: { resolve: () => ({ route: { path: menuPath } }) },
$store: {
getters: {
currentStore: () => 'cluster',
'cluster/count': () => 1,
}
}
describe('testing link type', () => {
it('should show the link type element if link is present on type with the specified label and target', () => {
const defaultLinkTypeProp = {
link: 'link-type-link',
label: 'link-type-label',
target: 'link-type-target'
};
const wrapper = shallowMount(Type as any, {
propsData: { type: defaultLinkTypeProp },
stubs: {
routerLink: createChildRenderingRouterLinkStub(),
Favorite: { template: `<div class=${ favoriteClass } />` }
},
});
const highlight = wrapper.find(`.${ className }`);
const link = wrapper.find(`li[data-testid="link-type"]`);
expect(highlight.exists()).toBe(true);
expect(link.text()).toBe(defaultLinkTypeProp.label);
expect(link.find('a').attributes('target')).toBe(defaultLinkTypeProp.target);
});
});
});

View File

@ -0,0 +1,36 @@
import { RouterLinkStub } from '@vue/test-utils';
import { NavigationFailure, Route } from 'vue-router';
/**
* See {@link RouterLinkSlotArgument} in vue-router
*/
export interface RouterLinkSlotArgumentOptional {
href?: string;
route?: Route;
navigate?: (e?: MouseEvent) => Promise<undefined | NavigationFailure>;
isActive?: boolean;
isExactActive?: boolean;
}
/**
* This is a workaround because VueUtils RouterLinkStub doesn't currently support the slot api.
*
* See @link https://github.com/vuejs/vue-test-utils/issues/1803#issuecomment-940884170
*
* @param slotProps Provide arguments that you want passed to the child rendered by router-link
* @returns A stub
*/
export function createChildRenderingRouterLinkStub(slotProps?: RouterLinkSlotArgumentOptional): typeof RouterLinkStub | any {
return {
...RouterLinkStub,
render() {
return this.$scopedSlots.default({
href: slotProps?.href || '',
route: slotProps?.route || ({} as any),
navigate: slotProps?.navigate || (() => {}),
isActive: slotProps?.isActive || false,
isExactActive: slotProps?.isExactActive || false,
});
}
};
}