diff --git a/cypress/e2e/po/detail/fleet/fleet.cattle.io.application.po.ts b/cypress/e2e/po/detail/fleet/fleet.cattle.io.application.po.ts index 793252b7691..85d3a7ecee5 100644 --- a/cypress/e2e/po/detail/fleet/fleet.cattle.io.application.po.ts +++ b/cypress/e2e/po/detail/fleet/fleet.cattle.io.application.po.ts @@ -5,7 +5,7 @@ import PagePo from '@/cypress/e2e/po/pages/page.po'; */ export default class FleetApplicationDetailsPo extends PagePo { private static createPath(fleetWorkspace: string, appName: string, type: string) { - return `/c/_/fleet/application/${ type }/${ fleetWorkspace }/${ appName }`; + return `/c/_/fleet/${ type }/${ fleetWorkspace }/${ appName }`; } static goTo(path: string): Cypress.Chainable { diff --git a/cypress/e2e/po/pages/fleet/fleet.cattle.io.application.po.ts b/cypress/e2e/po/pages/fleet/fleet.cattle.io.application.po.ts index 0a78a25556e..2691763d212 100644 --- a/cypress/e2e/po/pages/fleet/fleet.cattle.io.application.po.ts +++ b/cypress/e2e/po/pages/fleet/fleet.cattle.io.application.po.ts @@ -11,7 +11,7 @@ import ResourceTablePo from '@/cypress/e2e/po/components/resource-table.po'; import RadioGroupInputPo from '@/cypress/e2e/po/components/radio-group-input.po'; export class FleetApplicationListPagePo extends BaseListPagePo { - static url = `/c/_/fleet/application`; + static url = `/c/_/fleet`; constructor() { super(FleetApplicationListPagePo.url); @@ -41,7 +41,7 @@ export class FleetApplicationCreatePo extends BaseDetailPagePo { } constructor() { - super('/c/_/fleet/application/create'); + super('/c/_/fleet/create'); } createGitRepo() { @@ -55,7 +55,7 @@ export class FleetApplicationCreatePo extends BaseDetailPagePo { export class FleetGitRepoCreateEditPo extends BaseDetailPagePo { private static createPath(fleetWorkspace?: string, gitRepoName?: string) { - const root = `/c/_/fleet/application/fleet.cattle.io.gitrepo`; + const root = `/c/_/fleet/fleet.cattle.io.gitrepo`; return fleetWorkspace ? `${ root }/${ fleetWorkspace }/${ gitRepoName }` : `${ root }/create`; } @@ -139,7 +139,7 @@ export class FleetGitRepoCreateEditPo extends BaseDetailPagePo { export class FleetGitRepoDetailsPo extends BaseDetailPagePo { private static createPath(fleetWorkspace: string, gitRepoName: string) { - return `/c/_/fleet/application/fleet.cattle.io.gitrepo/${ fleetWorkspace }/${ gitRepoName }`; + return `/c/_/fleet/fleet.cattle.io.gitrepo/${ fleetWorkspace }/${ gitRepoName }`; } static goTo(path: string): Cypress.Chainable { diff --git a/cypress/e2e/po/pages/fleet/fleet.cattle.io.helmop.po.ts b/cypress/e2e/po/pages/fleet/fleet.cattle.io.helmop.po.ts index 31db17dbe81..4dfe386302d 100644 --- a/cypress/e2e/po/pages/fleet/fleet.cattle.io.helmop.po.ts +++ b/cypress/e2e/po/pages/fleet/fleet.cattle.io.helmop.po.ts @@ -6,7 +6,7 @@ import RadioGroupInputPo from '@/cypress/e2e/po/components/radio-group-input.po' export class FleetHelmOpCreateEditPo extends BaseDetailPagePo { private static createPath(fleetWorkspace?: string, helmOpName?: string) { - const root = `/c/_/fleet/application/fleet.cattle.io.helmop`; + const root = `/c/_/fleet/fleet.cattle.io.helmop`; return fleetWorkspace ? `${ root }/${ fleetWorkspace }/${ helmOpName }` : `${ root }/create`; } diff --git a/docusaurus/docs/extensions/api/nav/product-registration.md b/docusaurus/docs/extensions/api/nav/product-registration.md index 25af5fa31f3..9bfdc5d0a4c 100644 --- a/docusaurus/docs/extensions/api/nav/product-registration.md +++ b/docusaurus/docs/extensions/api/nav/product-registration.md @@ -341,6 +341,20 @@ A page inside a product that renders a Vue component you provide. Equivalent to | `labelKey` | `string` | Yes* | Translation key for the label | | `component` | `RouteComponent` | Yes | Vue component to render | | `weight` | `number` | No | Side-menu ordering (bigger number on top) | +| `config` | [`CustomPageConfiguration`](#CustomPageConfiguration) | No | Optional configuration for the page | + +### `CustomPageConfiguration` + +Configuration options for a custom page, passed via the `config` property of `ProductChildCustomPage`. These control visibility conditions, routing, and navigation behavior. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `ifHave` | `boolean` | No | Display only if a condition is met (relates to `IF_HAVE` in the store) | +| `ifFeature` | `string` | No | Display only if the specified feature flag is present | +| `ifHaveType` | `string` | No | Display only if the specified resource type exists | +| `ifHaveVerb` | `string` | No | Used with `ifHaveType` — display only if the resource type allows this verb (`GET`, `POST`, `PUT`, `DELETE`) | +| `namespaced` | `boolean` | No | Whether this custom page is namespaced | +| `exact` | `boolean` | No | Whether this custom page requires an exact route match | ### `ProductChildResourcePage` @@ -350,13 +364,31 @@ A page that displays a Kubernetes resource type using Rancher Dashboard's built- | --- | --- | --- | --- | | `type` | `string` | Yes | Kubernetes resource type (e.g. `'provisioning.cattle.io.cluster'`) | | `weight` | `number` | No | Side-menu ordering (bigger number on top) | -| `config` | `ConfigureTypeConfiguration` | No | Resource page options (creatable, editable, removable, etc.) | +| `config` | [`ResourcePageConfiguration`](#ResourcePageConfiguration) | No | Resource page options (creatable, editable, removable, etc.) | | `headers` | `HeaderOptions[]` | No | Custom table column headers for the list view (client-side pagination). See [Table headers](#table-headers-headers) | | `sspHeaders` | `PaginationHeaderOptions[]` | No | Custom table column headers for the list view (server-side pagination). See [Server-side pagination headers](#server-side-pagination-headers-sspheaders) | | `overrideListResourceName` | `string` | No | Override the display name for this resource type in the list view. See [Renaming types](#renaming-types-overridelistresourcename) | | `hideFromNav` | `boolean` | No | Hide this resource type from the side-menu entirely. See [Hiding types](#hiding-types-from-navigation-hidefromnav) | | `hideBulkActions` | `boolean` | No | Hide bulk action buttons (e.g. delete) for this resource type in the list view. See [Hiding bulk actions](#hiding-bulk-actions-hidebulkactions) | +### `ResourcePageConfiguration` + +Configuration options for a resource page, passed via the `config` property of `ProductChildResourcePage`. These control how the resource behaves in list, detail, and edit views. + +| Property | Type | Required | Description | +| --- | --- | --- | --- | +| `listCreateButtonLabelKey` | `string` | No | Translation key to override the "Create" button label in the list view | +| `isCreatable` | `boolean` | No | If `false`, disable creation even if the schema allows it | +| `isEditable` | `boolean` | No | If `false`, disable editing | +| `isRemovable` | `boolean` | No | If `false`, disable remove/delete | +| `showState` | `boolean` | No | If `false`, hide state in columns and masthead | +| `showAge` | `boolean` | No | If `false`, hide age in columns and masthead | +| `showConfigView` | `boolean` | No | If `false`, hide the config button in the masthead when in view mode | +| `showListMasthead` | `boolean` | No | If `false`, hide the masthead in the list view | +| `canYaml` | `boolean` | No | If `false`, disable YAML editing and viewing | +| `resourceEditMasthead` | `boolean` | No | Show the masthead in the edit resource component | +| `localOnly` | `boolean` | No | Hide this type from the nav/search bar on downstream clusters (only show in the `local` cluster) | +| `namespaced` | `boolean` | No | Whether this resource page is namespaced | ### `ProductChildGroup` diff --git a/shell/components/ResourceList/Masthead.vue b/shell/components/ResourceList/Masthead.vue index 5c844a68e93..821caba2ba5 100644 --- a/shell/components/ResourceList/Masthead.vue +++ b/shell/components/ResourceList/Masthead.vue @@ -102,7 +102,9 @@ export default { } }); - if (currPluginName && plugins[currPluginName]?.topLevelProduct) { + // the flag "topLevelProduct" only exists in the V2 product registration model + // same for the flag "startRouteWithProduct", we want to make sure both flags are true before we change the route structure + if (currPluginName && plugins[currPluginName]?.topLevelProduct && plugins[currPluginName]?.startRouteWithProduct) { // override create route for extension resource lists formRoute = { name: `${ this.$route.name }-create`, params: { ...params, product: this.$store.getters['productId'] } }; overrideCreateLocationByExtension = true; diff --git a/shell/config/product/fleet.js b/shell/config/product/fleet.js deleted file mode 100644 index c7d56f3697e..00000000000 --- a/shell/config/product/fleet.js +++ /dev/null @@ -1,156 +0,0 @@ -import { DSL } from '@shell/store/type-map'; -import { FLEET } from '@shell/config/types'; -import { STATE, NAME as NAME_COL, AGE, FLEET_APPLICATION_TYPE } from '@shell/config/table-headers'; -import { FLEET as FLEET_FEATURE } from '@shell/store/features'; -import { BLANK_CLUSTER } from '@shell/store/store-types.js'; - -export const SOURCE_TYPE = { - REPO: 'repo', - OCI: 'oci', - TARBALL: 'tarball', -}; - -export const NAME = 'fleet'; -export const CHART_NAME = 'fleet'; - -export function init(store) { - const { - product, - basicType, - weightType, - configureType, - headers, - // mapType, - virtualType, - } = DSL(store, NAME); - - product({ - ifHaveType: new RegExp(`${ FLEET.GIT_REPO }|${ FLEET.HELM_OP }`, 'i'), - ifFeature: FLEET_FEATURE, - icon: 'fleet', - inStore: 'management', - removable: false, - showClusterSwitcher: false, - showWorkspaceSwitcher: true, - extendable: true, - to: { - name: 'c-cluster-fleet', - params: { cluster: BLANK_CLUSTER } - }, - }); - - virtualType({ - labelKey: 'fleet.dashboard.menuLabel', - icon: 'folder', - group: 'Root', - namespaced: false, - name: FLEET.DASHBOARD, - weight: 112, - route: { - name: 'c-cluster-fleet', - params: { resource: FLEET.DASHBOARD, cluster: BLANK_CLUSTER } - }, - exact: true, - }); - - virtualType({ - labelKey: 'fleet.application.menuLabel', - group: 'Root', - namespaced: true, - name: FLEET.APPLICATION, - weight: 111, - route: { - name: 'c-cluster-fleet-application', - params: { resource: FLEET.APPLICATION, cluster: BLANK_CLUSTER } - }, - exact: false, - }); - - configureType(FLEET.APPLICATION, { - subTypes: [ - FLEET.GIT_REPO, - FLEET.HELM_OP - ], - location: { - name: 'c-cluster-fleet-application', - params: { resource: FLEET.APPLICATION, cluster: BLANK_CLUSTER } - }, - listGroups: [ - { - icon: 'icon-list-flat', - value: 'none', - tooltipKey: 'resourceTable.groupBy.none', - }, - { - icon: 'icon-repository', - value: 'kind', - field: 'kind', - tooltipKey: 'fleet.application.groupBy', - hideColumn: FLEET_APPLICATION_TYPE.name, - } - ], - listGroupsWillOverride: true, - }); - - basicType([ - FLEET.DASHBOARD, - FLEET.APPLICATION, - FLEET.CLUSTER, - FLEET.CLUSTER_GROUP, - FLEET.WORKSPACE, - ]); - - configureType(FLEET.CLUSTER, { isCreatable: false }); - - weightType(FLEET.CLUSTER, 108, true); - weightType(FLEET.CLUSTER_GROUP, 107, true); - - headers(FLEET.WORKSPACE, [ - STATE, - NAME_COL, - { - name: 'gitRepos', - labelKey: 'tableHeaders.gitRepos', - value: 'counts.gitRepos', - sort: 'counts.gitRepos', - formatter: 'Number', - }, - { - name: 'helmOps', - labelKey: 'tableHeaders.helmOps', - value: 'counts.helmOps', - sort: 'counts.helmOps', - formatter: 'Number', - }, - { - name: 'clusters', - labelKey: 'tableHeaders.clusters', - value: 'counts.clusters', - sort: 'counts.clusters', - formatter: 'Number', - }, - { - name: 'clusterGroups', - labelKey: 'tableHeaders.clusterGroups', - value: 'counts.clusterGroups', - sort: 'counts.clusterGroups', - formatter: 'Number', - }, - AGE - ]); - - basicType([ - FLEET.GIT_REPO, - FLEET.HELM_OP, - FLEET.BUNDLE, - FLEET.TOKEN, - FLEET.BUNDLE_NAMESPACE_MAPPING, - FLEET.GIT_REPO_RESTRICTION - ], 'resources'); - - configureType(FLEET.GIT_REPO, { showListMasthead: false }); - configureType(FLEET.HELM_OP, { showListMasthead: false }); - - weightType(FLEET.GIT_REPO, 110, true); - weightType(FLEET.HELM_OP, 109, true); -} diff --git a/shell/config/product/fleet.ts b/shell/config/product/fleet.ts new file mode 100644 index 00000000000..ff82f02e3cd --- /dev/null +++ b/shell/config/product/fleet.ts @@ -0,0 +1,140 @@ +import { IExtension } from '@shell/core/types'; +import { + ProductMetadata, + ProductChildCustomPage, + ProductChildResourcePage, + ProductChildGroup +} from '@shell/core/plugin-types'; + +import { FLEET } from '@shell/config/types'; +import { + STATE, NAME as NAME_COL, AGE, FLEET_APPLICATION_TYPE, + FLEET_GIT_REPOS, FLEET_HELM_OPS, FLEET_CLUSTERS, FLEET_CLUSTER_GROUPS +} from '@shell/config/table-headers'; +import { FLEET as FLEET_FEATURE } from '@shell/store/features'; + +// these consts are used in multiple places so we need to keep them here +export const SOURCE_TYPE = { + REPO: 'repo', + OCI: 'oci', + TARBALL: 'tarball', +}; + +export const NAME = 'fleet'; +export const CHART_NAME = 'fleet'; + +export function $init(prodReg: IExtension) { + const product: ProductMetadata = { + name: NAME, + icon: 'fleet', + ifHaveType: new RegExp(`${ FLEET.GIT_REPO }|${ FLEET.HELM_OP }`, 'i'), + ifFeature: FLEET_FEATURE, + removable: false, + showClusterSwitcher: false, + showWorkspaceSwitcher: true, + extendable: true, + labelKey: 'product.fleet', + startRouteWithProduct: false + }; + + const fleetDashboardPage: ProductChildCustomPage = { + name: 'dashboard', + labelKey: 'fleet.dashboard.menuLabel', + component: () => import('@shell/pages/c/_cluster/fleet/index.vue'), + config: { namespaced: false, exact: true }, + }; + + const fleetApplicationPage: ProductChildCustomPage = { + name: 'application', + labelKey: 'fleet.application.menuLabel', + component: () => import('@shell/pages/c/_cluster/fleet/application/index.vue'), + config: { namespaced: true, exact: false }, + customResourceConfig: { + type: FLEET.APPLICATION, + config: { + listGroups: [ + { + icon: 'icon-list-flat', + value: 'none', + tooltipKey: 'resourceTable.groupBy.none', + }, + { + icon: 'icon-repository', + value: 'kind', + field: 'kind', + tooltipKey: 'fleet.application.groupBy', + hideColumn: FLEET_APPLICATION_TYPE.name, + } + ], + listGroupsWillOverride: true, + subTypes: [ + FLEET.GIT_REPO, + FLEET.HELM_OP + ] + } + } + }; + + const fleetClusterPage: ProductChildResourcePage = { + type: FLEET.CLUSTER, + config: { isCreatable: false } + }; + + const fleetClusterGroupPage: ProductChildResourcePage = { type: FLEET.CLUSTER_GROUP }; + + const fleetWorkspacesPage: ProductChildResourcePage = { + type: FLEET.WORKSPACE, + headers: [ + STATE, + NAME_COL, + FLEET_GIT_REPOS, + FLEET_HELM_OPS, + FLEET_CLUSTERS, + FLEET_CLUSTER_GROUPS, + AGE + ] + }; + + const fleetGitRepoPage: ProductChildResourcePage = { + type: FLEET.GIT_REPO, + config: { showListMasthead: false } + }; + + const fleetHelmOpPage: ProductChildResourcePage = { + type: FLEET.HELM_OP, + config: { showListMasthead: false } + }; + + const fleetBundlePage: ProductChildResourcePage = { type: FLEET.BUNDLE }; + + const fleetTokenPage: ProductChildResourcePage = { type: FLEET.TOKEN }; + + const fleetBundleNamespaceMappingPage: ProductChildResourcePage = { type: FLEET.BUNDLE_NAMESPACE_MAPPING }; + + const fleetGitRepoRestrictionPage: ProductChildResourcePage = { type: FLEET.GIT_REPO_RESTRICTION }; + + const resourcesGroup: ProductChildGroup = { + name: 'resources', + label: 'Resources', + children: [ + fleetGitRepoPage, + fleetHelmOpPage, + fleetBundleNamespaceMappingPage, + fleetBundlePage, + fleetTokenPage, + fleetGitRepoRestrictionPage + ], + }; + + prodReg.addProduct( + product, + [ + fleetDashboardPage, + fleetApplicationPage, + fleetClusterPage, + fleetClusterGroupPage, + resourcesGroup, + fleetWorkspacesPage + ] + ); +} diff --git a/shell/config/router/navigation-guards/clusters.js b/shell/config/router/navigation-guards/clusters.js index d5d708bfbfa..27a2ae0ab3c 100644 --- a/shell/config/router/navigation-guards/clusters.js +++ b/shell/config/router/navigation-guards/clusters.js @@ -2,7 +2,7 @@ import { DEFAULT_WORKSPACE } from '@shell/config/types'; import { ClusterNotFoundError, RedirectToError } from '@shell/utils/error'; import { get } from '@shell/utils/object'; import { AFTER_LOGIN_ROUTE, WORKSPACE } from '@shell/store/prefs'; -import { NAME as FLEET_NAME } from '@shell/config/product/fleet.js'; +import { NAME as FLEET_NAME } from '@shell/config/product/fleet.ts'; import { getClusterFromRoute, getProductFromRoute, getPackageFromRoute, routeRequiresAuthentication } from '@shell/utils/router'; import { setProduct } from '@shell/utils/product'; import { validateResource } from '@shell/utils/resource'; diff --git a/shell/config/table-headers.js b/shell/config/table-headers.js index 600aa00ef97..92365c5cd39 100644 --- a/shell/config/table-headers.js +++ b/shell/config/table-headers.js @@ -1175,6 +1175,36 @@ export const UI_PLUGIN_CATALOG = [ } ]; +export const FLEET_GIT_REPOS = { + name: 'gitRepos', + labelKey: 'tableHeaders.gitRepos', + value: 'counts.gitRepos', + sort: 'counts.gitRepos', + formatter: 'Number', +}; + +export const FLEET_HELM_OPS = { + name: 'helmOps', + labelKey: 'tableHeaders.helmOps', + value: 'counts.helmOps', + sort: 'counts.helmOps', + formatter: 'Number', +}; +export const FLEET_CLUSTERS = { + name: 'clusters', + labelKey: 'tableHeaders.clusters', + value: 'counts.clusters', + sort: 'counts.clusters', + formatter: 'Number', +}; +export const FLEET_CLUSTER_GROUPS = { + name: 'clusterGroups', + labelKey: 'tableHeaders.clusterGroups', + value: 'counts.clusterGroups', + sort: 'counts.clusterGroups', + formatter: 'Number', +}; + // SECRETS export const PROJECT = { name: 'project', diff --git a/shell/core/__tests__/plugin-products-helpers.test.ts b/shell/core/__tests__/plugin-products-helpers.test.ts index b107f0f72a7..8cf1ea458f0 100644 --- a/shell/core/__tests__/plugin-products-helpers.test.ts +++ b/shell/core/__tests__/plugin-products-helpers.test.ts @@ -180,7 +180,7 @@ describe('plugin-products-helpers', () => { component: () => Promise.resolve({ default: {} }), }; - const route = generateVirtualTypeRoute('my-product', page); + const route = generateVirtualTypeRoute('my-product', page, { startRouteWithProduct: true }); expect(route.name).toBe('my-product-c-cluster-overview'); expect(route.path).toBe('my-product/c/:cluster/overview'); @@ -210,7 +210,7 @@ describe('plugin-products-helpers', () => { }); it('should handle group routes without page child', () => { - const route = generateVirtualTypeRoute('my-product', undefined); + const route = generateVirtualTypeRoute('my-product', undefined, { startRouteWithProduct: true }); expect(route.name).toBe('my-product-c-cluster'); expect(route.path).toBe('my-product/c/:cluster'); @@ -223,7 +223,7 @@ describe('plugin-products-helpers', () => { component: () => Promise.resolve({ default: {} }), }; - const route = generateVirtualTypeRoute('my-product', page, { omitPath: true }); + const route = generateVirtualTypeRoute('my-product', page, { omitPath: true, startRouteWithProduct: true }); expect(route.path).toBeUndefined(); expect(route.name).toBe('my-product-c-cluster-settings'); @@ -231,7 +231,7 @@ describe('plugin-products-helpers', () => { it('should use provided component', () => { const MockComponent = { template: '
test
' }; - const route = generateVirtualTypeRoute('my-product', undefined, { component: MockComponent }); + const route = generateVirtualTypeRoute('my-product', undefined, { component: MockComponent, startRouteWithProduct: true }); expect(route.component).toBe(MockComponent); }); @@ -242,7 +242,7 @@ describe('plugin-products-helpers', () => { it('should generate top-level extension resource route', () => { const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const route = generateConfigureTypeRoute('my-product', page); + const route = generateConfigureTypeRoute('my-product', page, { startRouteWithProduct: true }); expect(route.name).toBe('my-product-c-cluster-resource'); expect(route.path).toBe('my-product/c/:cluster/:resource'); @@ -278,7 +278,7 @@ describe('plugin-products-helpers', () => { it('should handle pages without a type gracefully', () => { const page: Partial = { name: 'clusters' }; - const route = generateConfigureTypeRoute('my-product', page as ProductChildPage); + const route = generateConfigureTypeRoute('my-product', page as ProductChildPage, { startRouteWithProduct: true }); expect(route.name).toBe('my-product-c-cluster-resource'); expect(route.params?.resource).toBeUndefined(); @@ -287,7 +287,7 @@ describe('plugin-products-helpers', () => { it('should omit path when omitPath option is true', () => { const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const route = generateConfigureTypeRoute('my-product', page, { omitPath: true }); + const route = generateConfigureTypeRoute('my-product', page, { omitPath: true, startRouteWithProduct: true }); expect(route.path).toBeUndefined(); expect(route.name).toBe('my-product-c-cluster-resource'); @@ -297,7 +297,7 @@ describe('plugin-products-helpers', () => { const MockComponent = { template: '
test
' }; const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const route = generateConfigureTypeRoute('my-product', page, { component: MockComponent }); + const route = generateConfigureTypeRoute('my-product', page, { component: MockComponent, startRouteWithProduct: true }); expect(route.component).toBe(MockComponent); }); @@ -308,7 +308,7 @@ describe('plugin-products-helpers', () => { it('should generate all resource routes for top-level extension', () => { const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const routes = generateResourceRoutes('my-product', page); + const routes = generateResourceRoutes('my-product', page, { startRouteWithProduct: true }); expect(routes).toHaveLength(4); expect(routes[0].name).toBe('my-product-c-cluster-resource'); @@ -327,7 +327,7 @@ describe('plugin-products-helpers', () => { it('should include meta data with asyncSetup for detail and edit routes', () => { const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const routes = generateResourceRoutes('my-product', page); + const routes = generateResourceRoutes('my-product', page, { startRouteWithProduct: true }); expect(routes[2].meta.asyncSetup).toBe(true); // detail route expect(routes[3].meta.asyncSetup).toBe(true); // edit route @@ -356,7 +356,7 @@ describe('plugin-products-helpers', () => { it('should include BLANK_CLUSTER in meta for top-level extensions', () => { const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const routes = generateResourceRoutes('my-product', page); + const routes = generateResourceRoutes('my-product', page, { startRouteWithProduct: true }); routes.forEach((route) => { expect((route.meta as any).cluster).toBe(BLANK_CLUSTER); @@ -378,7 +378,7 @@ describe('plugin-products-helpers', () => { it('should have component as async functions by default', () => { const page: ProductChildPage = { type: 'provisioning.cattle.io.cluster' }; - const routes = generateResourceRoutes('my-product', page); + const routes = generateResourceRoutes('my-product', page, { startRouteWithProduct: true }); routes.forEach((route) => { expect(typeof route.component).toBe('function'); @@ -386,6 +386,88 @@ describe('plugin-products-helpers', () => { }); }); + // ============= startRouteWithProduct option tests ============= + describe('startRouteWithProduct option', () => { + describe('generateVirtualTypeRoute', () => { + it('should generate route with c-cluster prefix when startRouteWithProduct is false', () => { + const page: ProductChildPage = { + name: 'overview', + label: 'Overview', + component: () => Promise.resolve({ default: {} }), + }; + + const route = generateVirtualTypeRoute('fleet', page, { startRouteWithProduct: false }); + + expect(route.name).toBe('c-cluster-fleet-overview'); + expect(route.path).toBe('c/:cluster/fleet/overview'); + expect(route.params).toStrictEqual({ + product: 'fleet', + cluster: BLANK_CLUSTER, + }); + }); + + it('should generate group route with c-cluster prefix when startRouteWithProduct is false and no page child', () => { + const route = generateVirtualTypeRoute('fleet', undefined, { startRouteWithProduct: false }); + + expect(route.name).toBe('c-cluster-fleet'); + expect(route.path).toBe('c/:cluster/fleet'); + }); + }); + + describe('generateConfigureTypeRoute', () => { + it('should generate route with c-cluster prefix when startRouteWithProduct is false', () => { + const page: ProductChildPage = { type: 'fleet.cattle.io.cluster' }; + + const route = generateConfigureTypeRoute('fleet', page, { startRouteWithProduct: false }); + + expect(route.name).toBe('c-cluster-fleet-resource'); + expect(route.path).toBe('c/:cluster/fleet/:resource'); + expect(route.params).toStrictEqual({ + product: 'fleet', + cluster: BLANK_CLUSTER, + resource: 'fleet.cattle.io.cluster', + }); + }); + }); + + describe('generateResourceRoutes', () => { + it('should generate all resource routes with c-cluster prefix when startRouteWithProduct is false', () => { + const page: ProductChildPage = { type: 'fleet.cattle.io.cluster' }; + + const routes = generateResourceRoutes('fleet', page, { startRouteWithProduct: false }); + + expect(routes).toHaveLength(4); + expect(routes[0].name).toBe('c-cluster-fleet-resource'); + expect(routes[0].path).toBe('c/:cluster/fleet/:resource'); + + expect(routes[1].name).toBe('c-cluster-fleet-resource-create'); + expect(routes[1].path).toBe('c/:cluster/fleet/:resource/create'); + + expect(routes[2].name).toBe('c-cluster-fleet-resource-id'); + expect(routes[2].path).toBe('c/:cluster/fleet/:resource/:id'); + + expect(routes[3].name).toBe('c-cluster-fleet-resource-namespace-id'); + expect(routes[3].path).toBe('c/:cluster/fleet/:resource/:namespace/:id'); + }); + + it('should include BLANK_CLUSTER and asyncSetup in meta for startRouteWithProduct false', () => { + const page: ProductChildPage = { type: 'fleet.cattle.io.cluster' }; + + const routes = generateResourceRoutes('fleet', page, { startRouteWithProduct: false }); + + routes.forEach((route) => { + expect((route.meta as any).cluster).toBe(BLANK_CLUSTER); + expect(route.meta.product).toBe('fleet'); + }); + + expect(routes[2].meta.asyncSetup).toBe(true); + expect(routes[3].meta.asyncSetup).toBe(true); + expect(routes[0].meta.asyncSetup).toBeUndefined(); + expect(routes[1].meta.asyncSetup).toBeUndefined(); + }); + }); + }); + // ============= Integration-like tests ============= describe('integration: complex product structure', () => { it('should handle a complete product config ordering and route generation', () => { diff --git a/shell/core/__tests__/plugin-products-type-guards.test.ts b/shell/core/__tests__/plugin-products-type-guards.test.ts new file mode 100644 index 00000000000..e29415abeaa --- /dev/null +++ b/shell/core/__tests__/plugin-products-type-guards.test.ts @@ -0,0 +1,180 @@ +import { + isProductSinglePage, + isProductChildGroup, + isProductChildWithComponent, + isProductChildWithComponentAndResourceConfig, + isProductChildWithType, + hasNameProperty, + hasTypeProperty, +} from '@shell/core/plugin-products-type-guards'; + +describe('plugin-products-type-guards', () => { + describe('isProductSinglePage', () => { + it('should return true when product has a component', () => { + const product = { + name: 'my-product', label: 'My Product', component: { name: 'Page' } + }; + + expect(isProductSinglePage(product)).toBe(true); + }); + + it('should return false when product has no component', () => { + const product = { name: 'my-product', label: 'My Product' }; + + expect(isProductSinglePage(product)).toBe(false); + }); + }); + + describe('isProductChildGroup', () => { + it('should return true when child has children array', () => { + const child = { + name: 'group', label: 'Group', children: [] + } as any; + + expect(isProductChildGroup(child)).toBe(true); + }); + + it('should return false when child has no children', () => { + const child = { + name: 'page', label: 'Page', component: { name: 'Page' } + } as any; + + expect(isProductChildGroup(child)).toBe(false); + }); + }); + + describe('isProductChildWithComponent', () => { + it('should return true for custom page with component and no children', () => { + const child = { + name: 'page', label: 'Page', component: { name: 'Page' } + } as any; + + expect(isProductChildWithComponent(child)).toBe(true); + }); + + it('should return false for group with component and children', () => { + const child = { + name: 'group', label: 'Group', component: { name: 'Page' }, children: [] + } as any; + + expect(isProductChildWithComponent(child)).toBe(false); + }); + + it('should return false for resource page with type', () => { + const child = { type: 'pod' } as any; + + expect(isProductChildWithComponent(child)).toBe(false); + }); + }); + + describe('isProductChildWithComponentAndResourceConfig', () => { + it('should return true when child has component and customResourceConfig', () => { + const child = { + name: 'application', + label: 'Application', + component: { name: 'ApplicationPage' }, + customResourceConfig: { + type: 'fleet-application', + config: { subTypes: ['fleet.cattle.io.gitrepo'] }, + }, + } as any; + + expect(isProductChildWithComponentAndResourceConfig(child)).toBe(true); + }); + + it('should return false when child has component but no customResourceConfig', () => { + const child = { + name: 'page', label: 'Page', component: { name: 'Page' } + } as any; + + expect(isProductChildWithComponentAndResourceConfig(child)).toBe(false); + }); + + it('should return false when child has customResourceConfig but no component', () => { + const child = { + name: 'page', + label: 'Page', + customResourceConfig: { type: 'some-type' }, + } as any; + + expect(isProductChildWithComponentAndResourceConfig(child)).toBe(false); + }); + + it('should return false for a group with component and customResourceConfig', () => { + const child = { + name: 'group', + label: 'Group', + component: { name: 'Page' }, + children: [], + customResourceConfig: { type: 'some-type' }, + } as any; + + expect(isProductChildWithComponentAndResourceConfig(child)).toBe(false); + }); + + it('should return false when customResourceConfig is undefined', () => { + const child = { + name: 'page', + label: 'Page', + component: { name: 'Page' }, + customResourceConfig: undefined, + } as any; + + expect(isProductChildWithComponentAndResourceConfig(child)).toBe(false); + }); + }); + + describe('isProductChildWithType', () => { + it('should return true for resource page with type string', () => { + const child = { type: 'provisioning.cattle.io.cluster' } as any; + + expect(isProductChildWithType(child)).toBe(true); + }); + + it('should return false for custom page with component', () => { + const child = { + name: 'page', label: 'Page', component: { name: 'Page' } + } as any; + + expect(isProductChildWithType(child)).toBe(false); + }); + + it('should return false for group with type', () => { + const child = { + name: 'group', label: 'Group', type: 'pod', children: [] + } as any; + + expect(isProductChildWithType(child)).toBe(false); + }); + }); + + describe('hasNameProperty', () => { + it('should return true when child has name string', () => { + const child = { name: 'test', label: 'Test' } as any; + + expect(hasNameProperty(child)).toBe(true); + }); + + it('should return false when child has no name', () => { + const child = { type: 'pod' } as any; + + expect(hasNameProperty(child)).toBe(false); + }); + }); + + describe('hasTypeProperty', () => { + it('should return true when child has type string', () => { + const child = { type: 'pod' } as any; + + expect(hasTypeProperty(child)).toBe(true); + }); + + it('should return false when child has no type', () => { + const child = { + name: 'page', label: 'Page', component: { name: 'Page' } + } as any; + + expect(hasTypeProperty(child)).toBe(false); + }); + }); +}); diff --git a/shell/core/__tests__/plugin-products.test.ts b/shell/core/__tests__/plugin-products.test.ts index 66ad2262069..b0c70608c69 100644 --- a/shell/core/__tests__/plugin-products.test.ts +++ b/shell/core/__tests__/plugin-products.test.ts @@ -59,6 +59,7 @@ jest.mock('@shell/core/productDebugger', () => ({ function createMockPlugin(): IExtension { return { _registerTopLevelProduct: jest.fn(), + _setStartRouteWithProduct: jest.fn(), addRoute: jest.fn(), enableServerSidePagination: jest.fn(), DSL: jest.fn((store, productName) => ({ @@ -3153,6 +3154,7 @@ describe('pluginProduct', () => { ignoreGroup: jest.fn(), mapType: jest.fn(), ignoreType: jest.fn(), + moveType: jest.fn(), }; jest.spyOn(mockPlugin, 'DSL').mockReturnValue(mockDSL); @@ -3188,6 +3190,7 @@ describe('pluginProduct', () => { ignoreGroup: jest.fn(), mapType: jest.fn(), ignoreType: jest.fn(), + moveType: jest.fn(), }; jest.spyOn(mockPlugin, 'DSL').mockReturnValue(mockDSL); @@ -3219,6 +3222,7 @@ describe('pluginProduct', () => { ignoreGroup: jest.fn(), mapType: jest.fn(), ignoreType: jest.fn(), + moveType: jest.fn(), }; jest.spyOn(mockPlugin, 'DSL').mockReturnValue(mockDSL); @@ -4605,6 +4609,191 @@ describe('pluginProduct', () => { }); }); + describe('customResourceConfig support', () => { + it('should call configureType for customResourceConfig when custom page has it', () => { + const mockPlugin = createMockPlugin(); + const mockStore = createMockStore(); + const mockDSL = { + product: jest.fn(), + basicType: jest.fn(), + labelGroup: jest.fn(), + setGroupDefaultType: jest.fn(), + weightGroup: jest.fn(), + virtualType: jest.fn(), + configureType: jest.fn(), + weightType: jest.fn(), + headers: jest.fn(), + hideBulkActions: jest.fn(), + mapGroup: jest.fn(), + ignoreGroup: jest.fn(), + mapType: jest.fn(), + ignoreType: jest.fn(), + moveType: jest.fn(), + }; + + (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL); + + const productMetadata: ProductMetadata = { + name: 'fleet', + label: 'Fleet', + }; + + const applicationPage: ProductChildCustomPage = { + name: 'application', + label: 'Applications', + component: { name: 'ApplicationPage' }, + customResourceConfig: { + type: 'fleet-application', + config: { subTypes: ['fleet.cattle.io.gitrepo', 'fleet.cattle.io.helmop'] }, + }, + }; + + const config: ProductChild[] = [applicationPage]; + const pluginProduct = new PluginProduct(mockPlugin, productMetadata, config); + + pluginProduct.apply(mockPlugin, mockStore); + + expect(mockDSL.virtualType).toHaveBeenCalledTimes(1); + expect(mockDSL.configureType).toHaveBeenCalledWith('fleet-application', expect.objectContaining({ subTypes: ['fleet.cattle.io.gitrepo', 'fleet.cattle.io.helmop'] })); + }); + + it('should not call configureType for customResourceConfig when custom page does not have it', () => { + const mockPlugin = createMockPlugin(); + const mockStore = createMockStore(); + const mockDSL = { + product: jest.fn(), + basicType: jest.fn(), + labelGroup: jest.fn(), + setGroupDefaultType: jest.fn(), + weightGroup: jest.fn(), + virtualType: jest.fn(), + configureType: jest.fn(), + weightType: jest.fn(), + headers: jest.fn(), + hideBulkActions: jest.fn(), + mapGroup: jest.fn(), + ignoreGroup: jest.fn(), + mapType: jest.fn(), + ignoreType: jest.fn(), + moveType: jest.fn(), + }; + + (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL); + + const productMetadata: ProductMetadata = { + name: 'my-product', + label: 'My Product', + }; + + const overviewPage: ProductChildCustomPage = { + name: 'overview', + label: 'Overview', + component: { name: 'OverviewPage' }, + }; + + const pluginProduct = new PluginProduct(mockPlugin, productMetadata, [overviewPage]); + + pluginProduct.apply(mockPlugin, mockStore); + + expect(mockDSL.virtualType).toHaveBeenCalledTimes(1); + expect(mockDSL.configureType).not.toHaveBeenCalled(); + }); + + it('should pass all config options from customResourceConfig to configureType', () => { + const mockPlugin = createMockPlugin(); + const mockStore = createMockStore(); + const mockDSL = { + product: jest.fn(), + basicType: jest.fn(), + labelGroup: jest.fn(), + setGroupDefaultType: jest.fn(), + weightGroup: jest.fn(), + virtualType: jest.fn(), + configureType: jest.fn(), + weightType: jest.fn(), + headers: jest.fn(), + hideBulkActions: jest.fn(), + mapGroup: jest.fn(), + ignoreGroup: jest.fn(), + mapType: jest.fn(), + ignoreType: jest.fn(), + moveType: jest.fn(), + }; + + (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL); + + const productMetadata: ProductMetadata = { + name: 'fleet', + label: 'Fleet', + }; + + const applicationPage: ProductChildCustomPage = { + name: 'application', + label: 'Applications', + component: { name: 'ApplicationPage' }, + customResourceConfig: { + type: 'fleet-application', + config: { + listGroups: [ + { + icon: 'icon-list-flat', + value: 'none', + }, + ], + listGroupsWillOverride: true, + subTypes: ['fleet.cattle.io.gitrepo'], + }, + }, + }; + + const pluginProduct = new PluginProduct(mockPlugin, productMetadata, [applicationPage]); + + pluginProduct.apply(mockPlugin, mockStore); + + expect(mockDSL.configureType).toHaveBeenCalledWith('fleet-application', expect.objectContaining({ + listGroups: expect.arrayContaining([expect.objectContaining({ icon: 'icon-list-flat', value: 'none' })]), + listGroupsWillOverride: true, + subTypes: ['fleet.cattle.io.gitrepo'], + })); + }); + }); + + describe('startRouteWithProduct', () => { + it('should call _setStartRouteWithProduct with true by default for new products', () => { + const mockPlugin = createMockPlugin(); + const productMetadata: ProductMetadata = { + name: 'my-product', + label: 'My Product', + }; + + new PluginProduct(mockPlugin, productMetadata, []); + + expect(mockPlugin._setStartRouteWithProduct).toHaveBeenCalledWith(true); + }); + + it('should call _setStartRouteWithProduct with false when product sets startRouteWithProduct: false', () => { + const mockPlugin = createMockPlugin(); + const productMetadata: ProductMetadata = { + name: 'fleet', + label: 'Fleet', + startRouteWithProduct: false, + }; + + new PluginProduct(mockPlugin, productMetadata, []); + + expect(mockPlugin._setStartRouteWithProduct).toHaveBeenCalledWith(false); + }); + + it('should call _setStartRouteWithProduct with false when extending an existing product', () => { + const mockPlugin = createMockPlugin(); + const validStandardProduct = StandardProductNames.EXPLORER; + + new PluginProduct(mockPlugin, validStandardProduct, []); + + expect(mockPlugin._setStartRouteWithProduct).toHaveBeenCalledWith(false); + }); + }); + describe('addProduct duplicate guard', () => { it('should throw when addProduct is called twice with the same product name (object form)', () => { const plugin = new Plugin('test-extension'); diff --git a/shell/core/plugin-products-base.ts b/shell/core/plugin-products-base.ts index 110de209825..49edc535a17 100644 --- a/shell/core/plugin-products-base.ts +++ b/shell/core/plugin-products-base.ts @@ -1,7 +1,7 @@ import { IExtension } from '@shell/core/types'; import { ProductChild, ProductMetadata, - ConfigureTypeConfiguration, VirtualTypeConfiguration, + ResourcePageConfiguration, CustomPageConfiguration, ProductChildCustomPage, VueRouteComponent, OverviewPageRoutingMetadata } from '@shell/core/plugin-types'; @@ -10,6 +10,7 @@ import pluginProductsHelpers from '@shell/core/plugin-products-helpers'; import { isProductChildGroup, isProductChildWithComponent, + isProductChildWithComponentAndResourceConfig, isProductChildWithType, hasNameProperty, hasTypeProperty, @@ -28,6 +29,15 @@ export abstract class BasePluginProduct { protected registeredPageNames: Set = new Set(); + /** + * By default, routes for top-level product pages start with the product name (e.g. "my-product/c/:cluster/:resource") + * but for internal purposes we might want to keep the previous route structure for certain products (e.g. "c/:cluster/my-product/:resource") - + * only to be used in very special usecases (internal use only - check FLEET product config for an example). + * + * in that case, we can set this to "false" and the route generation will adjust accordingly + */ + protected startRouteWithProduct?: boolean; + // Maps user-friendly group name → internal resolved name (e.g. 'monitoring' → 'myapp-monitoring') // Populated during processGroupRecursively, consumed by moveToGroup resolution in processProductLevelDSLOptions protected groupNameMap: Map = new Map(); @@ -219,6 +229,9 @@ export abstract class BasePluginProduct { // this is the default "to" route for a product with config (at least 1 item on config) ordered by weight if (defaultResource) { const firstConfig = this.config[0]; + const routeGenConfig = { + omitPath: true, extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct + }; if (isProductChildGroup(firstConfig)) { // First config item is a group @@ -228,28 +241,30 @@ export abstract class BasePluginProduct { if (!firstConfig.component) { // Group without component - route to first child if (isProductChildWithType(entryChild)) { - defaultRoute = pluginProductsHelpers.generateConfigureTypeRoute(this.name, entryChild, { omitPath: true, extendProduct: !this.isNewProduct }); + defaultRoute = pluginProductsHelpers.generateConfigureTypeRoute(this.name, entryChild, routeGenConfig); } else if (isProductChildWithComponent(entryChild)) { - defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, entryChild, { omitPath: true, extendProduct: !this.isNewProduct }); + defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, entryChild, routeGenConfig); } } else { // Group with component - route to the group overview page (which will render the group's component and side-menu) - defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, this.generateMetadataForGroupOverviewPageRouting(firstConfig.name, firstConfig.component), { omitPath: true, extendProduct: !this.isNewProduct }); + defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, this.generateMetadataForGroupOverviewPageRouting(firstConfig.name, firstConfig.component), routeGenConfig); } } else if (firstConfig.component) { // Group with component but no children - route to the group page itself - defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, this.generateMetadataForGroupOverviewPageRouting(firstConfig.name, firstConfig.component), { omitPath: true, extendProduct: !this.isNewProduct }); + defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, this.generateMetadataForGroupOverviewPageRouting(firstConfig.name, firstConfig.component), routeGenConfig); } } else if (isProductChildWithType(firstConfig)) { // Simple configureType page (resource page) - defaultRoute = pluginProductsHelpers.generateConfigureTypeRoute(this.name, firstConfig, { omitPath: true, extendProduct: !this.isNewProduct }); + defaultRoute = pluginProductsHelpers.generateConfigureTypeRoute(this.name, firstConfig, routeGenConfig); } else if (isProductChildWithComponent(firstConfig)) { // Simple virtual type page (custom page) - defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, firstConfig, { omitPath: true, extendProduct: !this.isNewProduct }); + defaultRoute = pluginProductsHelpers.generateVirtualTypeRoute(this.name, firstConfig, routeGenConfig); } } else if (this.isNewProduct) { + const routeGenConfig = { omitPath: true, startRouteWithProduct: this.startRouteWithProduct }; + // this is the "to" route for a simple page product (no config items) - defaultRoute = pluginProductsHelpers.generateTopLevelExtensionSimpleBaseRoute(this.name, { omitPath: true }); + defaultRoute = pluginProductsHelpers.generateTopLevelExtensionSimpleBaseRoute(this.name, routeGenConfig); basicType(names); } @@ -343,7 +358,7 @@ export abstract class BasePluginProduct { this.registeredPageNames.add(finalName); this.pageIdMap.set(item.name, finalName); - const virtualTypeConfig: VirtualTypeConfiguration = { + const virtualTypeConfig: CustomPageConfiguration = { label: item.label, labelKey: item.labelKey, namespaced: false, @@ -357,12 +372,34 @@ export abstract class BasePluginProduct { virtualTypeConfig.exact = true; virtualTypeConfig.overview = true; // Pass group metadata as pageChild so the route gets a unique path segment (e.g. /product/c/:cluster/groupName) - virtualTypeConfig.route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, this.generateMetadataForGroupOverviewPageRouting(item.name, item.component as ProductChildCustomPage['component']), { extendProduct: !this.isNewProduct }); + virtualTypeConfig.route = pluginProductsHelpers.generateVirtualTypeRoute( + parentName, + this.generateMetadataForGroupOverviewPageRouting(item.name, item.component as ProductChildCustomPage['component']), + { extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct } + ); } else { - virtualTypeConfig.route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, item, { extendProduct: !this.isNewProduct }); + virtualTypeConfig.route = pluginProductsHelpers.generateVirtualTypeRoute( + parentName, + item, + { extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct } + ); } virtualType({ ...virtualTypeConfig, ...(isProductChildWithComponent(item) ? item.config || {} : {}) }); + + // If the item has a component and customResourceConfig, it's a special case of virtualType that also configures a resource type in the nav + // so we need to also configure the resource type with the same name as the page name to make it show up in the nav and be grouped correctly + // see "FLEET" product configuration for an example of this use case + if (isProductChildWithComponentAndResourceConfig(item)) { + const resourceConfig = item.customResourceConfig as NonNullable; + + // we need to register the configureType twice + // this first one binds the custom page with the resource page part (allows for count to be displayed on the side menu) + configureType(finalName, { ...resourceConfig.config }); + + // this second one is for the actual resource page configuration (CRUD operations, headers, etc.) + configureType(resourceConfig.type, { ...resourceConfig.config }); + } } else if (isProductChildWithType(item)) { // Page with a "type" specified maps to a configureType const typeValue = item.type; @@ -375,9 +412,13 @@ export abstract class BasePluginProduct { this.registeredPageNames.add(typeValue); this.pageIdMap.set(typeValue, typeValue); - const route = pluginProductsHelpers.generateConfigureTypeRoute(parentName, item, { extendProduct: !this.isNewProduct }); + const route = pluginProductsHelpers.generateConfigureTypeRoute( + parentName, + item, + { extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct } + ); - const configureTypeConfig: ConfigureTypeConfiguration = { + const configureTypeConfig: ResourcePageConfiguration = { isCreatable: true, isEditable: true, isRemovable: true, @@ -431,9 +472,19 @@ export abstract class BasePluginProduct { component: EmptyProductPage }; - route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, pageForRoute, { extendProduct: !this.isNewProduct }); + route = pluginProductsHelpers.generateVirtualTypeRoute( + parentName, + pageForRoute, + { extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct } + ); } else { - route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, this.generateMetadataForGroupOverviewPageRouting(child.name, child.component), { component: child.component, extendProduct: !this.isNewProduct }); + route = pluginProductsHelpers.generateVirtualTypeRoute( + parentName, + this.generateMetadataForGroupOverviewPageRouting(child.name, child.component), + { + component: child.component, extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct + } + ); } // add the route for the group page/parent @@ -447,7 +498,13 @@ export abstract class BasePluginProduct { this.surfaceError('Custom pages cannot have a "type" property - only resource pages can use "type".'); } - const route = pluginProductsHelpers.generateVirtualTypeRoute(parentName, child, { component: child.component, extendProduct: !this.isNewProduct }); + const route = pluginProductsHelpers.generateVirtualTypeRoute( + parentName, + child, + { + component: child.component, extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct + } + ); plugin.addRoute(route); } else if (isProductChildWithType(child)) { @@ -459,7 +516,11 @@ export abstract class BasePluginProduct { if (!this.addedResourceRoutes) { this.addedResourceRoutes = true; - const resourceRoutes = pluginProductsHelpers.generateResourceRoutes(parentName, child, { extendProduct: !this.isNewProduct }); + const resourceRoutes = pluginProductsHelpers.generateResourceRoutes( + parentName, + child, + { extendProduct: !this.isNewProduct, startRouteWithProduct: this.startRouteWithProduct } + ); resourceRoutes.forEach((resRoute) => { plugin.addRoute(resRoute); diff --git a/shell/core/plugin-products-extending.ts b/shell/core/plugin-products-extending.ts index d8b7a7503b1..91ee07c1280 100644 --- a/shell/core/plugin-products-extending.ts +++ b/shell/core/plugin-products-extending.ts @@ -17,6 +17,11 @@ export class ExtendingPluginProduct extends BasePluginProduct { // existing standard product - no need to add routes this.name = productName; + this.startRouteWithProduct = false; + + // extending an existing product - we need to set the startRouteWithProduct to false because we + // don't want the route to start with the product name (e.g. "c/:cluster/:resource" vs "my-product/c/:cluster/:resource") + plugin._setStartRouteWithProduct(false); if (this.config?.length > 0) { this.processConfigChildren(); diff --git a/shell/core/plugin-products-helpers.ts b/shell/core/plugin-products-helpers.ts index 714832d9f60..1c2de728c77 100644 --- a/shell/core/plugin-products-helpers.ts +++ b/shell/core/plugin-products-helpers.ts @@ -97,9 +97,18 @@ class PluginProductsHelpers { // VIRTUAL TYPE ROUTES - TOP LEVEL EXTENSION private generateVirtualTypeRouteForNewProduct(parentName: string, pageChild: ProductChildCustomPage | OverviewPageRoutingMetadata | undefined, options: ProductRegistrationRouteGenerationOptions = {}): RouteRecordRawWithParams { - const { component, omitPath } = options; - const name = pageChild ? `${ parentName }-c-cluster-${ pageChild.name }` : `${ parentName }-c-cluster`; - const path = pageChild ? `${ parentName }/c/:cluster/${ pageChild.name }` : `${ parentName }/c/:cluster`; + const { component, omitPath, startRouteWithProduct } = options; + + let name; + let path; + + if (startRouteWithProduct) { + name = pageChild ? `${ parentName }-c-cluster-${ pageChild.name }` : `${ parentName }-c-cluster`; + path = pageChild ? `${ parentName }/c/:cluster/${ pageChild.name }` : `${ parentName }/c/:cluster`; + } else { + name = pageChild ? `c-cluster-${ parentName }-${ pageChild.name }` : `c-cluster-${ parentName }`; + path = pageChild ? `c/:cluster/${ parentName }/${ pageChild.name }` : `c/:cluster/${ parentName }`; + } const route: RouteRecordRawWithParams = { name, @@ -152,11 +161,22 @@ class PluginProductsHelpers { // CONFIGURE TYPE ROUTES - TOP LEVEL EXTENSION private generateConfigureTypeRouteForNewProduct(parentName: string, pageChild: ProductChildResourcePage | undefined, options: ProductRegistrationRouteGenerationOptions = {}): RouteRecordRawWithParams { - const { component, omitPath } = options; + const { component, omitPath, startRouteWithProduct } = options; + + let name; + let path; + + if (startRouteWithProduct) { + name = `${ parentName }-c-cluster-resource`; + path = `${ parentName }/c/:cluster/:resource`; + } else { + name = `c-cluster-${ parentName }-resource`; + path = `c/:cluster/${ parentName }/:resource`; + } const route: RouteRecordRawWithParams = { - name: `${ parentName }-c-cluster-resource`, - path: `${ parentName }/c/:cluster/:resource`, + name, + path, params: { product: parentName, cluster: BLANK_CLUSTER, resource: pageChild?.type }, @@ -181,7 +201,7 @@ class PluginProductsHelpers { if (options.extendProduct) { return this.generateResourceRoutesForExistingProduct(parentName, pageChild); } else { - return this.generateResourceRoutesForNewProduct(parentName); + return this.generateResourceRoutesForNewProduct(parentName, options); } } @@ -222,33 +242,44 @@ class PluginProductsHelpers { } // RESOURCE ROUTES - TOP LEVEL EXTENSION - private generateResourceRoutesForNewProduct(parentName: string) { + private generateResourceRoutesForNewProduct(parentName: string, options: ProductRegistrationRouteGenerationOptions = {}): RouteRecordRawWithParams[] { const interopDefault = (promise: Promise) => promise.then((page) => page.default || page); + let namePrefix; + let pathPrefix; + + if (options.startRouteWithProduct) { + namePrefix = `${ parentName }-c-cluster`; + pathPrefix = `${ parentName }/c/:cluster`; + } else { + namePrefix = `c-cluster-${ parentName }`; + pathPrefix = `c/:cluster/${ parentName }`; + } + return [ { - name: `${ parentName }-c-cluster-resource`, - path: `${ parentName }/c/:cluster/:resource`, + name: `${ namePrefix }-resource`, + path: `${ pathPrefix }/:resource`, component: () => interopDefault(import('@shell/pages/c/_cluster/_product/_resource/index.vue')), meta: { product: parentName, cluster: BLANK_CLUSTER } }, { - name: `${ parentName }-c-cluster-resource-create`, - path: `${ parentName }/c/:cluster/:resource/create`, + name: `${ namePrefix }-resource-create`, + path: `${ pathPrefix }/:resource/create`, component: () => interopDefault(import('@shell/pages/c/_cluster/_product/_resource/create.vue')), meta: { product: parentName, cluster: BLANK_CLUSTER } }, { - name: `${ parentName }-c-cluster-resource-id`, - path: `${ parentName }/c/:cluster/:resource/:id`, + name: `${ namePrefix }-resource-id`, + path: `${ pathPrefix }/:resource/:id`, component: () => interopDefault(import('@shell/pages/c/_cluster/_product/_resource/_id.vue')), meta: { product: parentName, cluster: BLANK_CLUSTER, asyncSetup: true } }, { - name: `${ parentName }-c-cluster-resource-namespace-id`, - path: `${ parentName }/c/:cluster/:resource/:namespace/:id`, + name: `${ namePrefix }-resource-namespace-id`, + path: `${ pathPrefix }/:resource/:namespace/:id`, component: () => interopDefault(import('@shell/pages/c/_cluster/_product/_resource/_namespace/_id.vue')), meta: { product: parentName, cluster: BLANK_CLUSTER, asyncSetup: true diff --git a/shell/core/plugin-products-top-level.ts b/shell/core/plugin-products-top-level.ts index aee86710bc5..56bee99a847 100644 --- a/shell/core/plugin-products-top-level.ts +++ b/shell/core/plugin-products-top-level.ts @@ -38,9 +38,17 @@ export class TopLevelPluginProduct extends BasePluginProduct { this.name = prodName; this.product = product; + // If the product doesn't explicitly set startRouteWithProduct to false, we want the route to start with the product name (e.g. "my-product/c/:cluster/:resource" vs "c/:cluster/my-product/:resource") + // this is needed for top-level products that have list views with cluster and resource params, to avoid route conflicts with other products that might have the same resource types in their list views + this.startRouteWithProduct = product.startRouteWithProduct ?? true; + // register the product as a top-level product in the plugin object (will be needed for routes correction when on list views for top-level products) plugin._registerTopLevelProduct(); + // if the product has a `startRouteWithProduct` field, we need to set it on the plugin so that the route correction logic + // in plugin-products-helpers can work properly for list view routes of top-level products + plugin._setStartRouteWithProduct(this.startRouteWithProduct); + this.processConfigChildren(); // If the product has a `component` field, then this is a single page product diff --git a/shell/core/plugin-products-type-guards.ts b/shell/core/plugin-products-type-guards.ts index 892cfcd5dd8..01b406c26c5 100644 --- a/shell/core/plugin-products-type-guards.ts +++ b/shell/core/plugin-products-type-guards.ts @@ -20,6 +20,10 @@ export function isProductChildWithComponent(child: ProductChild): child is Produ return 'component' in child && child.component !== undefined && !isProductChildGroup(child); } +export function isProductChildWithComponentAndResourceConfig(child: ProductChild): child is ProductChildCustomPage { + return 'component' in child && child.component !== undefined && !isProductChildGroup(child) && 'customResourceConfig' in child && child.customResourceConfig !== undefined; +} + export function isProductChildWithType(child: ProductChild): child is ProductChildResourcePage { return 'type' in child && typeof child.type === 'string' && !isProductChildGroup(child); } diff --git a/shell/core/plugin-types.ts b/shell/core/plugin-types.ts index 9ae1001a389..8958897ccea 100644 --- a/shell/core/plugin-types.ts +++ b/shell/core/plugin-types.ts @@ -35,6 +35,12 @@ export type ProductRegistrationRouteGenerationOptions = { * Generated route should omit the path property */ omitPath?: boolean; + /** + * @internal + * Whether the route should start with the product name or not (e.g. "my-product/c/:cluster/:resource" vs "c/:cluster/my-product/:resource") + * only to be used in very special usecases (internal use only - check FLEET product config for an example) + * */ + startRouteWithProduct?: boolean; } /** @@ -72,7 +78,7 @@ export type ProductChildMetadata = { /** * Represents the allowed configuration for a custom page (virtualType) */ -export type VirtualTypeConfiguration = { +export type CustomPageConfiguration = { /** Display only if condition is met (relates to IF_HAVE in shell/store/type-map) */ ifHave?: boolean; /** Display only if feature is present (relates to shell/store/features) */ @@ -81,23 +87,44 @@ export type VirtualTypeConfiguration = { ifHaveType?: string; /** Used in conjunction with "ifHaveType", display only if resource type allows this verb (GET, POST, PUT, DELETE) */ ifHaveVerb?: string; - /** Display label for the custom page */ + /** + * @internal + * Display label for the custom page + */ label?: string; - /** Translation key for the label */ + /** + * @internal + * Translation key for the label + * */ labelKey?: string; - /** Name of the page (unique identifier) */ + /** + * @internal + * Name of the page (unique identifier) + */ name?: string; - /** Entry route definition for this custom page */ + /** + * @internal + * Entry route definition for this custom page + */ route?: RouteRecordRawWithParams | PluginRouteRecordRaw | Object; - /** Icon for the custom page (relates to icons in https://github.com/rancher/icons) */ + /** + * @internal + * Icon for the custom page (relates to icons in https://github.com/rancher/icons) + */ icon?: 'compass'; /** Whether this custom page is namespaced or not */ namespaced?: boolean; - /** Ordering weight for the custom page */ + /** + * @internal + * Ordering weight for the custom page + */ weight?: number; /** Whether this custom page is exact match */ exact?: boolean; - /** Whether this custom page will act as an overview page */ + /** + * @internal + * Whether this custom page will act as an overview page + * */ overview?: boolean; /** Whether this custom page has an exact path match */ 'exact-path'?: boolean; @@ -106,7 +133,11 @@ export type VirtualTypeConfiguration = { /** * Represents the allowed configuration for a resource page (configureType) */ -export type ConfigureTypeConfiguration = { +export type ResourcePageConfiguration = { + /** + * @internal + * Entry route definition for this custom page + */ /** Override for the name displayed */ displayName?: string; /** Override for the create button string on a list view */ @@ -129,25 +160,47 @@ export type ConfigureTypeConfiguration = { canYaml?: boolean; /** Show the Masthead in the edit resource component */ resourceEditMasthead?: boolean; - /** Entry route definition for this resource page */ + /** + * @internal + * Entry route definition for this resource page + */ customRoute?: RouteRecordRawWithParams; /** Hide this type from the nav/search bar on downstream clusters (will only show in "local" cluster) */ localOnly?: boolean; + /** Whether this custom page is namespaced or not */ + namespaced?: boolean; + /** + * @internal + * Whether this custom page has list groups (definition for grouping items in the list view) + */ + listGroups?: { + /** Icon for the group (relates to icons in rancher-icons */ + icon?: string; + /** Value for the group (used for grouping items in the list view) */ + value?: string; + /** Field for the group (used for grouping items in the list view) */ + field?: string; + /** Column to hide when this group is active */ + hideColumn?: string; + /** Tooltip key for the group */ + tooltipKey?: string; + }[]; + /** + * @internal + * Whether the provided list groups will override the default grouping options (e.g. group by namespace, group by cluster, etc.) or be added to them + */ + listGroupsWillOverride?: boolean; + /** + * @internal + * Use this to configure subtypes that should be shown in the list view for this type (e.g. show "pods" and "deployments" in the list view for "workloads") + */ + subTypes?: string[]; // resource: undefined; // Use this resource in ResourceDetails instead // resourceDetail: undefined; // Use this resource specifically for ResourceDetail's detail component // resourceEdit: undefined; // Use this resource specifically for ResourceDetail's edit component // depaginate: undefined; // Use this to depaginate requests for this type // notFilterNamespace: undefined; // Define namespaces that do not need to be filtered // used in configureType options, to be typed later if needed - // listGroups: [ - // { - // icon: 'icon-role-binding', - // value: 'node', - // field: 'roleDisplay', - // hideColumn: ROLE.name, - // tooltipKey: 'resourceTable.groupBy.role' - // } - // ] } /** @@ -165,16 +218,6 @@ export type OverviewPageRoutingMetadata = { component: VueRouteComponent; } -/** - * Represents a custom page with a component - */ -export type ProductChildCustomPage = ProductChildMetadata & { - /** Component to render for this custom page */ - component: VueRouteComponent; - /** Optional configuration for the page */ - config?: VirtualTypeConfiguration; -}; - /** * Represents a resource page with a type (K8s resource) */ @@ -182,7 +225,7 @@ export type ProductChildResourcePage = { /** K8s resource type name for a resource page */ type: string; /** Optional configuration for the resource page */ - config?: ConfigureTypeConfiguration; + config?: ResourcePageConfiguration; /** Ordering weight for this page among its siblings */ weight?: number; /** Use this to override the resource name used in the list view for this type */ @@ -197,6 +240,27 @@ export type ProductChildResourcePage = { sspHeaders?: PaginationHeaderOptions[]; }; +/** + * Represents a custom page with a component + */ +export type ProductChildCustomPage = ProductChildMetadata & { + /** Component to render for this custom page */ + component: VueRouteComponent; + /** Optional configuration for the page */ + config?: CustomPageConfiguration; + /** + * @internal + * Use `customResourceConfig` to provide an optional custom resource configuration for this custom page. + * Use this for special cases where you want to render a custom component for the resource page instead of the default generated one based on the resource schema. + * + * This allows you to specify a custom component, route, and other settings for the resource page. If `customResourceConfig` is not + * provided, the resource page will be rendered with default configuration. + * + * check "FLEET" product config for an example + */ + customResourceConfig?: ProductChildResourcePage; +}; + /** * Represents a page item (custom page or resource page) in a product's config * - For custom pages: use `component` with `name` and `label`/`labelKey` @@ -228,6 +292,12 @@ export type ProductMetadata = Omit void; export class Plugin implements IPlugin { public id: string; public name: string; + // Indicates if this plugin registers a top-level product (new product registration) public topLevelProduct = false; + // Indicates if the route for this product should start with the product name (new product registration) + public startRouteWithProduct = false; public types: ExtensionManagerTypes = {}; public l10n: { [key: string]: Function[] } = {}; public modelExtensions: { [key: string]: Function[] } = {}; @@ -120,10 +123,18 @@ export class Plugin implements IPlugin { this._validators = vals; } + // used in the new prod reg to register in the "plugin" object + // whether the product is a new product or an extension of an existing product, so that we can check in the product registration logic and throw errors if someone tries to register a product that already exists, or extend a product that doesn't exist _registerTopLevelProduct() { this.topLevelProduct = true; } + // used in the new prod reg to register in the "plugin" object + // whether the route for this product should start with the product name, so that we can check in the product registration logic and set the correct route structure for list views of top-level products + _setStartRouteWithProduct(val: boolean) { + this.startRouteWithProduct = val; + } + // Track which products the plugin creates // Legacy DSL method DSL(store: any, productName: string) { diff --git a/shell/core/types.ts b/shell/core/types.ts index d7b81ff5e09..5cd44852f7f 100644 --- a/shell/core/types.ts +++ b/shell/core/types.ts @@ -291,6 +291,11 @@ export interface ProductOptions { */ to?: PluginRouteRecordRaw; + /** + * the product is considered a built-in/core product that can't be removed by users if set to true + */ + removable?: boolean; + /** * Alternative to the icon property. Uses require */ @@ -301,6 +306,11 @@ export interface ProductOptions { */ name?: string; + /** + * controls whether a workspace switcher dropdown appears in the header (instead of the namespace filter) if set to true + */ + showWorkspaceSwitcher?: boolean; + /** * */ @@ -317,8 +327,6 @@ export interface ProductOptions { * Leaving these here for completeness but I don't think these should be advertised as useable to plugin creators. */ // ifHaveVerb: string | RegExp; - // removable: string; - // showWorkspaceSwitcher: boolean; // supportRoute: string; // typeStoreMap: string; } @@ -694,6 +702,11 @@ export interface IExtension { * @internal - DO NOT USE - Internal API only */ _registerTopLevelProduct(): void; + /** + * + * @internal - DO NOT USE - Internal API only + */ + _setStartRouteWithProduct(value: boolean): void; /** * Add a product to the sidebar, with children and a side menu for navigation for internal pages diff --git a/shell/models/fleet-application.js b/shell/models/fleet-application.js index 5c9eadbfe5d..c50eba62dbd 100644 --- a/shell/models/fleet-application.js +++ b/shell/models/fleet-application.js @@ -269,6 +269,11 @@ export default class FleetApplication extends SteveModel { }; } + // this fixes the missing "product" param in the done route + get doneParams() { + return this.doneOverride?.params; + } + get doneRoute() { return this.doneOverride?.name; } diff --git a/shell/plugins/dashboard-store/resource-class.js b/shell/plugins/dashboard-store/resource-class.js index 0f75eb12e54..a01f0ae01ab 100644 --- a/shell/plugins/dashboard-store/resource-class.js +++ b/shell/plugins/dashboard-store/resource-class.js @@ -1361,7 +1361,7 @@ export default class Resource { return window.$globalApp.$router; } - get isProdRegistrationV2TopLevelProductResoure() { + get isProdRegistrationV2TopLevelProductResource() { // this is the logic to determine if the resource is top level product or not // changes c-cluster-product-resource to product-c-cluster-resource // this is for the new extension product registration model @@ -1375,11 +1375,12 @@ export default class Resource { }); // the flag "topLevelProduct" only exists in the V2 product registration model - return plugins[currPluginName]?.topLevelProduct || false; + // same for the flag "startRouteWithProduct", we want to make sure both flags are true before we change the route structure + return (plugins[currPluginName]?.topLevelProduct && plugins[currPluginName]?.startRouteWithProduct) || false; } get listLocation() { - if (this.isProdRegistrationV2TopLevelProductResoure) { + if (this.isProdRegistrationV2TopLevelProductResource) { return { name: `${ this.$rootGetters['productId'] }-c-cluster-resource`, params: { @@ -1406,7 +1407,7 @@ export default class Resource { const id = this.id?.replace(/.*\//, ''); - if (this.isProdRegistrationV2TopLevelProductResoure) { + if (this.isProdRegistrationV2TopLevelProductResource) { return { name: `${ this.$rootGetters['productId'] }-c-cluster-resource${ schema?.attributes?.namespaced ? '-namespace' : '' }-id`, params: { @@ -1982,7 +1983,7 @@ export default class Resource { const type = this.parentNameOverride || this.$rootGetters['type-map/labelFor'](this.schema); let toRoute = null; - if (this.isProdRegistrationV2TopLevelProductResoure) { + if (this.isProdRegistrationV2TopLevelProductResource) { toRoute = { name: `${ this.$rootGetters['productId'] }-c-cluster-resource-id`, params: {