diff --git a/shell/components/SideNav.vue b/shell/components/SideNav.vue
index 9b3856ee0f2..e772c9fbbc8 100644
--- a/shell/components/SideNav.vue
+++ b/shell/components/SideNav.vue
@@ -423,17 +423,24 @@ export default {
-
-
- {{ t('nav.clusterTools') }}
-
+
+
+ {{ t('nav.clusterTools') }}
+
+
-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 };
- },
-};
-
-
-
-
-
-
-
diff --git a/shell/components/nav/Header.vue b/shell/components/nav/Header.vue
index feb5fbf3939..d4ce077299f 100644
--- a/shell/components/nav/Header.vue
+++ b/shell/components/nav/Header.vue
@@ -690,27 +690,48 @@ export default {
diff --git a/shell/components/nav/Type.vue b/shell/components/nav/Type.vue
index cbd1fdd6d1e..60a8a1045e5 100644
--- a/shell/components/nav/Type.vue
+++ b/shell/components/nav/Type.vue
@@ -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 {
-
- {{ type.labelKey ? t(type.labelKey) : (type.labelDisplay || type.label) }}
-
-
-
-
-
-
-
+
+
+
{{ count }}
-
-
+ v-if="showFavorite || namespaceIcon || showCount"
+ class="count"
+ >
+
+
+ {{ count }}
+
+
+
{{ type.label }}
diff --git a/shell/components/nav/__tests__/Type.test.ts b/shell/components/nav/__tests__/Type.test.ts
index 831aef62e2e..fe68fe41c6d 100644
--- a/shell/components/nav/__tests__/Type.test.ts
+++ b/shell/components/nav/__tests__/Type.test.ts
@@ -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 }
+ });
+
+ const elementWithSelector = wrapper.find(`a[href='${ fakeHref }']`);
- expect(highlight.exists()).toBe(false);
+ expect(elementWithSelector.exists()).toBe(true);
+ });
+
+ 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 }
+ });
+
+ const elementWithSelector = wrapper.find(`.${ activeClass }`);
+
+ elementWithSelector.trigger('click');
+
+ expect(navigate).toHaveBeenCalledTimes(1);
+ });
});
- 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,
- }
- }
- },
+ 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);
});
- const highlight = wrapper.find(`.${ className }`);
+ 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(highlight.exists()).toBe(false);
+ 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);
+ });
});
- it('given no matching values', () => {
- const wrapper = mount(Type, {
- propsData: { type: {} },
- stubs: { nLink: RouterLinkStub },
- mocks: {
- $route: {
- hash: 'hash',
- path: 'path',
- },
- $router: { resolve: () => ({ route: { path: 'whatever' } }) },
- },
+ 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);
});
- const highlight = wrapper.find(`.${ className }`);
+ 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(highlight.exists()).toBe(false);
+ expect(elementWithSelector.exists()).toBe(true);
+ });
});
- 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 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: `` }
},
- $router: { resolve: () => ({ route: { path: 'many/parts' } }) },
- $store: {
- getters: {
- currentStore: () => 'cluster',
- 'cluster/count': () => 1,
- }
- }
- },
+ 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: `` }
+ },
+ mocks: { $store: storeMock }
+ });
+
+ const favoriteElement = wrapper.find(`.${ favoriteClass }`);
+
+ expect(favoriteElement.exists()).toBe(false);
+ });
- expect(highlight.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: `` }
+ },
+ 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.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 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: `` }
+ },
+ mocks: { $store: storeMock }
+ });
+
+ const typeCount = wrapper.find(`span[data-testid="type-count"]`);
+
+ expect(Number.parseInt(typeCount.text())).toBe(count);
+ });
+
+ it('should show count if in store', async() => {
+ const wrapper = shallowMount(Type as any, {
+ localVue,
+ propsData: { type: defaultRouteTypeProp },
+ stubs: {
+ routerLink: createChildRenderingRouterLinkStub(),
+ Favorite: { template: `` }
},
- $router: { resolve: () => ({ route: { path: menuPath } }) },
- $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(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: `` }
+ },
+ mocks: {
+
+ $store: {
+ getters: {
+ currentStore: () => 'cluster',
+ 'cluster/count': () => null,
+ }
}
}
- },
+ });
+
+ const typeCount = wrapper.find(`span[data-testid="type-count"]`);
+
+ expect(typeCount.exists()).toBe(false);
+ });
+ });
+
+ 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: `` }
+ },
+ 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: `` }
+ },
+ mocks: { $store: storeMock }
+ });
+
+ const namespaced = wrapper.find(`i[data-testid="type-namespaced"]`);
- expect(highlight.exists()).toBe(false);
+ 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: `` }
},
});
- 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);
});
});
});
diff --git a/shell/utils/unit-tests/ChildRenderingRouterLinkStub.ts b/shell/utils/unit-tests/ChildRenderingRouterLinkStub.ts
new file mode 100644
index 00000000000..9c12efa061d
--- /dev/null
+++ b/shell/utils/unit-tests/ChildRenderingRouterLinkStub.ts
@@ -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;
+ 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,
+ });
+ }
+ };
+}