@rancher/shell 3.0.12-rc.1 → 3.0.12-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (134) hide show
  1. package/assets/images/providers/entraid-black.svg +4 -0
  2. package/assets/images/providers/entraid.svg +9 -0
  3. package/assets/images/vendor/entraid.svg +9 -0
  4. package/assets/styles/app.scss +0 -1
  5. package/assets/translations/en-us.yaml +19 -17
  6. package/assets/translations/zh-hans.yaml +4 -8
  7. package/chart/__tests__/S3.test.ts +10 -3
  8. package/components/CountBox.vue +20 -0
  9. package/components/CreateDriver.vue +0 -12
  10. package/components/DetailText.vue +12 -3
  11. package/components/SelectIconGrid.vue +5 -0
  12. package/components/__tests__/CountBox.test.ts +72 -0
  13. package/components/__tests__/DetailText.test.ts +113 -0
  14. package/components/fleet/FleetClusterTargets/index.vue +18 -1
  15. package/components/form/InputWithSelect.vue +18 -10
  16. package/components/form/KeyValue.vue +17 -1
  17. package/components/form/LabeledSelect.vue +82 -24
  18. package/components/form/Select.vue +73 -56
  19. package/components/form/ServiceNameSelect.vue +13 -11
  20. package/components/form/__tests__/KeyValue.test.ts +66 -0
  21. package/components/form/__tests__/NodeScheduling.test.ts +9 -0
  22. package/components/form/labeled-select-utils/useLabeledSelectPagination.ts +138 -0
  23. package/components/nav/Group.vue +7 -6
  24. package/components/nav/Header.vue +24 -3
  25. package/components/nav/NotificationCenter/Notification.vue +4 -1
  26. package/components/nav/NotificationCenter/NotificationHeader.vue +20 -8
  27. package/components/nav/NotificationCenter/__tests__/NotificationHeader.test.ts +80 -0
  28. package/components/nav/Type.vue +8 -7
  29. package/components/nav/WindowManager/index.vue +2 -1
  30. package/components/nav/WorkspaceSwitcher.vue +13 -0
  31. package/components/nav/__tests__/Group.test.ts +67 -0
  32. package/components/nav/__tests__/Header.test.ts +235 -0
  33. package/components/nav/__tests__/Type.test.ts +20 -3
  34. package/components/templates/default.vue +34 -4
  35. package/components/templates/home.vue +12 -25
  36. package/components/templates/plain.vue +13 -26
  37. package/composables/useLabeledFormElement.ts +10 -2
  38. package/composables/useLabeledSelect.ts +60 -0
  39. package/composables/useUserRetentionValidation.ts +1 -49
  40. package/config/cookies.js +0 -1
  41. package/config/labels-annotations.js +1 -0
  42. package/config/query-params.js +1 -0
  43. package/config/router/routes.js +0 -8
  44. package/core/__tests__/plugin-products.test.ts +616 -25
  45. package/core/plugin-products-base.ts +31 -14
  46. package/core/plugin-products-helpers.ts +5 -4
  47. package/core/plugin-types.ts +18 -3
  48. package/core/types.ts +3 -1
  49. package/detail/__tests__/management.cattle.io.fleetworkspace.test.ts +128 -0
  50. package/detail/management.cattle.io.fleetworkspace.vue +49 -0
  51. package/edit/__tests__/fleet.cattle.io.helmop.test.ts +9 -0
  52. package/edit/__tests__/kontainerDriver.test.ts +0 -13
  53. package/edit/__tests__/nodeDriver.test.ts +5 -11
  54. package/edit/__tests__/resources.cattle.io.restore.test.ts +9 -0
  55. package/edit/auditlog.cattle.io.auditpolicy/__tests__/__snapshots__/General.test.ts.snap +6 -0
  56. package/edit/auth/__tests__/oidc.test.ts +54 -0
  57. package/edit/auth/azuread.vue +1 -1
  58. package/edit/auth/oidc.vue +8 -0
  59. package/edit/kontainerDriver.vue +1 -2
  60. package/edit/nodeDriver.vue +0 -2
  61. package/edit/provisioning.cattle.io.cluster/AgentEnv.vue +1 -0
  62. package/edit/provisioning.cattle.io.cluster/__tests__/AgentEnv.test.ts +25 -0
  63. package/edit/provisioning.cattle.io.cluster/index.vue +70 -99
  64. package/initialize/App.vue +29 -2
  65. package/initialize/install-plugins.js +0 -2
  66. package/list/__tests__/management.cattle.io.feature.test.ts +105 -0
  67. package/list/catalog.cattle.io.app.vue +25 -5
  68. package/list/management.cattle.io.feature.vue +1 -1
  69. package/list/management.cattle.io.fleetworkspace.vue +8 -0
  70. package/machine-config/amazonec2.vue +1 -0
  71. package/mixins/chart.js +40 -9
  72. package/models/__tests__/catalog.cattle.io.app.test.ts +15 -1
  73. package/models/__tests__/catalog.cattle.io.clusterrepo.test.ts +84 -0
  74. package/models/__tests__/chart.test.ts +99 -6
  75. package/models/__tests__/management.cattle.io.feature.test.ts +131 -0
  76. package/models/__tests__/monitoring.coreos.com.alertmanagerconfig.test.ts +98 -0
  77. package/models/catalog.cattle.io.app.js +21 -17
  78. package/models/catalog.cattle.io.clusterrepo.js +39 -11
  79. package/models/chart.js +33 -19
  80. package/models/fleet-application.js +1 -1
  81. package/models/fleet.cattle.io.bundle.js +1 -1
  82. package/models/kontainerdriver.js +11 -0
  83. package/models/management.cattle.io.authconfig.js +5 -1
  84. package/models/management.cattle.io.cluster.js +0 -53
  85. package/models/management.cattle.io.feature.js +3 -3
  86. package/models/management.cattle.io.kontainerdriver.js +1 -26
  87. package/models/monitoring.coreos.com.alertmanagerconfig.js +31 -17
  88. package/models/nodedriver.js +7 -0
  89. package/package.json +13 -12
  90. package/pages/c/_cluster/apps/charts/__tests__/chart.test.ts +189 -0
  91. package/pages/c/_cluster/apps/charts/__tests__/index.test.ts +55 -0
  92. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +53 -0
  93. package/pages/c/_cluster/apps/charts/chart.vue +217 -33
  94. package/pages/c/_cluster/apps/charts/index.vue +2 -2
  95. package/pages/c/_cluster/apps/charts/install.vue +8 -3
  96. package/pages/c/_cluster/auth/user.retention/index.vue +55 -22
  97. package/pages/c/_cluster/manager/drivers/kontainerDriver/index.vue +5 -7
  98. package/pages/c/_cluster/uiplugins/PluginInfoPanel.vue +39 -2
  99. package/pages/c/_cluster/uiplugins/__tests__/PluginInfoPanel.test.ts +61 -0
  100. package/pages/c/_cluster/uiplugins/__tests__/index.test.ts +15 -10
  101. package/pages/c/_cluster/uiplugins/index.vue +23 -25
  102. package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +205 -1
  103. package/rancher-components/Form/LabeledInput/LabeledInput.vue +82 -4
  104. package/rancher-components/Form/ToggleSwitch/ToggleSwitch.vue +1 -1
  105. package/scripts/test-plugins-build.sh +5 -2
  106. package/server/server-middleware.js +2 -2
  107. package/static/humans.txt +1 -0
  108. package/static/robots.txt +34 -0
  109. package/static/welcome-cow.svg +18 -0
  110. package/store/__tests__/catalog.test.ts +161 -11
  111. package/store/auth.js +0 -3
  112. package/store/catalog.js +60 -8
  113. package/types/shell/index.d.ts +26 -22
  114. package/utils/__tests__/git.test.ts +270 -0
  115. package/utils/__tests__/inactivity.test.ts +316 -0
  116. package/utils/__tests__/object.test.ts +77 -0
  117. package/utils/__tests__/time.test.ts +14 -1
  118. package/utils/__tests__/url.test.ts +246 -0
  119. package/utils/object.js +33 -2
  120. package/utils/time.ts +5 -0
  121. package/vue.config.js +0 -9
  122. package/assets/images/providers/azuread-black.svg +0 -22
  123. package/assets/images/providers/azuread.svg +0 -25
  124. package/assets/images/vendor/azuread.svg +0 -18
  125. package/assets/styles/fonts/_dots.scss +0 -18
  126. package/components/EmberPage.vue +0 -622
  127. package/components/EmberPageView.vue +0 -39
  128. package/components/form/labeled-select-utils/labeled-select-pagination.ts +0 -116
  129. package/mixins/labeled-form-element.ts +0 -225
  130. package/pages/c/_cluster/explorer/tools/pages/_page.vue +0 -28
  131. package/pages/c/_cluster/manager/pages/_page.vue +0 -22
  132. package/pages/c/_cluster/mcapps/pages/_page.vue +0 -22
  133. package/plugins/ember-cookie.js +0 -17
  134. package/utils/ember-page.js +0 -30
@@ -1,7 +1,8 @@
1
1
  import { PluginProduct } from '@shell/core/plugin-products';
2
2
  import {
3
3
  ProductMetadata, ProductSinglePage, ProductChildPage,
4
- ProductChildGroup, StandardProductNames
4
+ ProductChildGroup, ProductChildCustomPage, ProductChildResourcePage,
5
+ ProductChild, StandardProductNames
5
6
  } from '@shell/core/plugin-types';
6
7
  import { IExtension } from '@shell/core/types';
7
8
 
@@ -891,9 +892,9 @@ describe('pluginProduct', () => {
891
892
  pluginProduct.apply(mockPlugin, mockStore);
892
893
 
893
894
  // Verify default route points to the group's component page (not first child)
894
- // When a group has a component, it should route to that component, not the first child
895
+ // When a group has a component, the route includes the group's name for proper side-menu highlighting
895
896
  expect(mockDSL.product).toHaveBeenCalledWith(
896
- expect.objectContaining({ to: expect.objectContaining({ name: 'groupwithpage-group' }) })
897
+ expect.objectContaining({ to: expect.objectContaining({ name: 'groupwithpage-settings' }) })
897
898
  );
898
899
 
899
900
  // Verify virtualType was still created for the group component
@@ -965,28 +966,6 @@ describe('pluginProduct', () => {
965
966
  new PluginProduct(mockPlugin, productMetadata, badConfig);
966
967
  }).toThrow('forEach');
967
968
  });
968
-
969
- it('should throw when extending standard product and group parent has component', () => {
970
- const mockPlugin = createMockPlugin();
971
- const config: ProductChildGroup[] = [
972
- {
973
- name: 'parent-group',
974
- label: 'Parent Group',
975
- component: { name: 'GroupComponent' },
976
- children: [
977
- {
978
- name: 'child',
979
- label: 'Child',
980
- component: { name: 'ChildComponent' },
981
- },
982
- ],
983
- },
984
- ];
985
-
986
- expect(() => {
987
- new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, config);
988
- }).toThrow('When extending an existing product, group parent items cannot have a component because of route matching conflicts.');
989
- });
990
969
  });
991
970
 
992
971
  describe('state verification', () => {
@@ -3153,6 +3132,13 @@ describe('pluginProduct', () => {
3153
3132
  virtualType: jest.fn(),
3154
3133
  configureType: jest.fn(),
3155
3134
  weightType: jest.fn(),
3135
+ headers: jest.fn(),
3136
+ hideBulkActions: jest.fn(),
3137
+ spoofedType: jest.fn(),
3138
+ mapGroup: jest.fn(),
3139
+ ignoreGroup: jest.fn(),
3140
+ mapType: jest.fn(),
3141
+ ignoreType: jest.fn(),
3156
3142
  };
3157
3143
 
3158
3144
  jest.spyOn(mockPlugin, 'DSL').mockReturnValue(mockDSL);
@@ -3181,6 +3167,13 @@ describe('pluginProduct', () => {
3181
3167
  virtualType: jest.fn(),
3182
3168
  configureType: jest.fn(),
3183
3169
  weightType: jest.fn(),
3170
+ headers: jest.fn(),
3171
+ hideBulkActions: jest.fn(),
3172
+ spoofedType: jest.fn(),
3173
+ mapGroup: jest.fn(),
3174
+ ignoreGroup: jest.fn(),
3175
+ mapType: jest.fn(),
3176
+ ignoreType: jest.fn(),
3184
3177
  };
3185
3178
 
3186
3179
  jest.spyOn(mockPlugin, 'DSL').mockReturnValue(mockDSL);
@@ -3205,6 +3198,13 @@ describe('pluginProduct', () => {
3205
3198
  virtualType: jest.fn(),
3206
3199
  configureType: jest.fn(),
3207
3200
  weightType: jest.fn(),
3201
+ headers: jest.fn(),
3202
+ hideBulkActions: jest.fn(),
3203
+ spoofedType: jest.fn(),
3204
+ mapGroup: jest.fn(),
3205
+ ignoreGroup: jest.fn(),
3206
+ mapType: jest.fn(),
3207
+ ignoreType: jest.fn(),
3208
3208
  };
3209
3209
 
3210
3210
  jest.spyOn(mockPlugin, 'DSL').mockReturnValue(mockDSL);
@@ -3216,4 +3216,595 @@ describe('pluginProduct', () => {
3216
3216
  expect(productCalls[0][0]).toStrictEqual(expect.objectContaining({ name: 'myproduct' }));
3217
3217
  });
3218
3218
  });
3219
+
3220
+ describe('documentation examples', () => {
3221
+ describe('quick start: string convenience method', () => {
3222
+ it('should create a new product from just a string name', () => {
3223
+ const mockPlugin = createMockPlugin();
3224
+ const pluginProduct = PluginProduct.fromName(mockPlugin, 'my-first-product');
3225
+
3226
+ expect(pluginProduct.newProduct).toBe(true);
3227
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
3228
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
3229
+ });
3230
+ });
3231
+
3232
+ describe('single page product', () => {
3233
+ it('should create a product with a single page component and no config', () => {
3234
+ const mockPlugin = createMockPlugin();
3235
+ const product: ProductSinglePage = {
3236
+ name: 'my-dashboard',
3237
+ label: 'My Dashboard',
3238
+ icon: 'globe',
3239
+ component: { name: 'DashboardPage' },
3240
+ };
3241
+
3242
+ const pluginProduct = new PluginProduct(mockPlugin, product, []);
3243
+
3244
+ expect(pluginProduct.newProduct).toBe(true);
3245
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
3246
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
3247
+ });
3248
+ });
3249
+
3250
+ describe('product with custom pages', () => {
3251
+ it('should register routes for each custom page', () => {
3252
+ const mockPlugin = createMockPlugin();
3253
+
3254
+ const overviewPage: ProductChildCustomPage = {
3255
+ name: 'overview',
3256
+ label: 'Overview',
3257
+ component: { name: 'OverviewPage' },
3258
+ weight: 2,
3259
+ };
3260
+
3261
+ const settingsPage: ProductChildCustomPage = {
3262
+ name: 'settings',
3263
+ label: 'Settings',
3264
+ component: { name: 'SettingsPage' },
3265
+ weight: 1,
3266
+ };
3267
+
3268
+ const product: ProductMetadata = {
3269
+ name: 'my-app',
3270
+ label: 'My App',
3271
+ icon: 'gear',
3272
+ };
3273
+
3274
+ const pluginProduct = new PluginProduct(mockPlugin, product, [overviewPage, settingsPage]);
3275
+
3276
+ expect(pluginProduct.newProduct).toBe(true);
3277
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
3278
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(2);
3279
+ });
3280
+ });
3281
+
3282
+ describe('product with resource pages', () => {
3283
+ it('should register resource CRUD routes for resource page items', () => {
3284
+ const mockPlugin = createMockPlugin();
3285
+
3286
+ const clusterPage: ProductChildResourcePage = {
3287
+ type: 'provisioning.cattle.io.cluster',
3288
+ weight: 2,
3289
+ config: {
3290
+ displayName: 'Clusters',
3291
+ isCreatable: true,
3292
+ isEditable: true,
3293
+ isRemovable: true,
3294
+ canYaml: true,
3295
+ },
3296
+ };
3297
+
3298
+ const nodePage: ProductChildResourcePage = {
3299
+ type: 'management.cattle.io.node',
3300
+ weight: 1,
3301
+ };
3302
+
3303
+ const product: ProductMetadata = {
3304
+ name: 'my-resources',
3305
+ label: 'My Resources',
3306
+ };
3307
+
3308
+ const pluginProduct = new PluginProduct(mockPlugin, product, [clusterPage, nodePage]);
3309
+
3310
+ expect(pluginProduct.newProduct).toBe(true);
3311
+ // Resource routes are only added once (shared CRUD routes) - mock generates list + detail = 2
3312
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(2);
3313
+ });
3314
+ });
3315
+
3316
+ describe('product with groups', () => {
3317
+ it('should register routes for a product with groups and standalone pages', () => {
3318
+ const mockPlugin = createMockPlugin();
3319
+
3320
+ const alertsPage: ProductChildCustomPage = {
3321
+ name: 'alerts',
3322
+ label: 'Alerts',
3323
+ component: { name: 'AlertsPage' },
3324
+ };
3325
+
3326
+ const metricsPage: ProductChildCustomPage = {
3327
+ name: 'metrics',
3328
+ label: 'Metrics',
3329
+ component: { name: 'MetricsPage' },
3330
+ };
3331
+
3332
+ const monitoringGroup: ProductChildGroup = {
3333
+ name: 'monitoring',
3334
+ label: 'Monitoring',
3335
+ weight: 2,
3336
+ children: [alertsPage, metricsPage],
3337
+ };
3338
+
3339
+ const overviewPage: ProductChildCustomPage = {
3340
+ name: 'overview',
3341
+ label: 'Overview',
3342
+ component: { name: 'OverviewPage' },
3343
+ weight: 3,
3344
+ };
3345
+
3346
+ const product: ProductMetadata = {
3347
+ name: 'my-platform',
3348
+ label: 'My Platform',
3349
+ };
3350
+
3351
+ const config: ProductChild[] = [overviewPage, monitoringGroup];
3352
+ const pluginProduct = new PluginProduct(mockPlugin, product, config);
3353
+
3354
+ expect(pluginProduct.newProduct).toBe(true);
3355
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
3356
+ // 1 standalone page + 1 group parent route + 2 group children routes = 4
3357
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(4);
3358
+ });
3359
+ });
3360
+
3361
+ describe('extending an existing product', () => {
3362
+ it('should extend explorer with a custom page', () => {
3363
+ const mockPlugin = createMockPlugin();
3364
+
3365
+ const customPage: ProductChildCustomPage = {
3366
+ name: 'my-custom-view',
3367
+ label: 'My Custom View',
3368
+ component: { name: 'MyCustomView' },
3369
+ };
3370
+
3371
+ const pluginProduct = new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, [customPage]);
3372
+
3373
+ expect(pluginProduct.newProduct).toBe(false);
3374
+ expect(mockPlugin._registerTopLevelProduct).not.toHaveBeenCalled();
3375
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
3376
+ });
3377
+ });
3378
+
3379
+ describe('mixed pages: custom + resource', () => {
3380
+ it('should register routes for both custom pages and resource pages together', () => {
3381
+ const mockPlugin = createMockPlugin();
3382
+
3383
+ const dashboardPage: ProductChildCustomPage = {
3384
+ name: 'dashboard',
3385
+ label: 'Dashboard',
3386
+ component: { name: 'Dashboard' },
3387
+ weight: 3,
3388
+ };
3389
+
3390
+ const clusterPage: ProductChildResourcePage = {
3391
+ type: 'provisioning.cattle.io.cluster',
3392
+ weight: 2,
3393
+ };
3394
+
3395
+ const settingsPage: ProductChildCustomPage = {
3396
+ name: 'settings',
3397
+ label: 'Settings',
3398
+ component: { name: 'Settings' },
3399
+ weight: 1,
3400
+ };
3401
+
3402
+ const product: ProductMetadata = {
3403
+ name: 'my-platform',
3404
+ label: 'My Platform',
3405
+ };
3406
+
3407
+ const config: ProductChild[] = [dashboardPage, clusterPage, settingsPage];
3408
+ const pluginProduct = new PluginProduct(mockPlugin, product, config);
3409
+
3410
+ expect(pluginProduct.newProduct).toBe(true);
3411
+ // 2 custom page routes + resource CRUD routes (list + detail = 2 from mock) = 4
3412
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(4);
3413
+ // Verify addRoute was called for both custom pages and resource routes
3414
+ const routeCalls = (mockPlugin.addRoute as jest.Mock).mock.calls;
3415
+ const hasCustomRoutes = routeCalls.some((call) => call[0]?.name?.includes('dashboard'));
3416
+ const hasResourceRoutes = routeCalls.some((call) => call[0]?.name?.includes('provisioning.cattle.io.cluster'));
3417
+
3418
+ expect(hasCustomRoutes).toBe(true);
3419
+ expect(hasResourceRoutes).toBe(true);
3420
+ });
3421
+ });
3422
+
3423
+ describe('group with its own page', () => {
3424
+ it('should register a route for the group page itself and its children', () => {
3425
+ const mockPlugin = createMockPlugin();
3426
+
3427
+ const alertsPage: ProductChildCustomPage = {
3428
+ name: 'alerts',
3429
+ label: 'Alerts',
3430
+ component: { name: 'AlertsPage' },
3431
+ };
3432
+
3433
+ const metricsPage: ProductChildCustomPage = {
3434
+ name: 'metrics',
3435
+ label: 'Metrics',
3436
+ component: { name: 'MetricsPage' },
3437
+ };
3438
+
3439
+ const monitoringGroup: ProductChildGroup = {
3440
+ name: 'monitoring',
3441
+ label: 'Monitoring',
3442
+ component: { name: 'MonitoringOverview' },
3443
+ children: [alertsPage, metricsPage],
3444
+ };
3445
+
3446
+ const product: ProductMetadata = {
3447
+ name: 'my-platform',
3448
+ label: 'My Platform',
3449
+ };
3450
+
3451
+ const pluginProduct = new PluginProduct(mockPlugin, product, [monitoringGroup]);
3452
+
3453
+ expect(pluginProduct.newProduct).toBe(true);
3454
+ // 1 group parent route (with component) + 2 children routes = 3
3455
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(3);
3456
+ });
3457
+
3458
+ it('should generate a route with the group name for proper side-menu highlighting', () => {
3459
+ const mockPlugin = createMockPlugin();
3460
+
3461
+ const childPage: ProductChildCustomPage = {
3462
+ name: 'child',
3463
+ label: 'Child',
3464
+ component: { name: 'ChildComponent' },
3465
+ };
3466
+
3467
+ const monitoringGroup: ProductChildGroup = {
3468
+ name: 'monitoring',
3469
+ label: 'Monitoring',
3470
+ component: { name: 'MonitoringOverview' },
3471
+ children: [childPage],
3472
+ };
3473
+
3474
+ const product: ProductMetadata = {
3475
+ name: 'my-platform',
3476
+ label: 'My Platform',
3477
+ };
3478
+
3479
+ new PluginProduct(mockPlugin, product, [monitoringGroup]);
3480
+
3481
+ // The group's own route should include the group name in the route name
3482
+ // This ensures the side-menu can highlight the correct item
3483
+ const routeCalls = (mockPlugin.addRoute as jest.Mock).mock.calls;
3484
+ const groupRoute = routeCalls.find((call) => call[0]?.name?.includes('monitoring'));
3485
+
3486
+ expect(groupRoute).toBeDefined();
3487
+ expect(groupRoute[0].name).toStrictEqual(expect.stringContaining('monitoring'));
3488
+ });
3489
+ });
3490
+
3491
+ describe('extending Cluster Explorer', () => {
3492
+ it('should extend explorer with a standalone page', () => {
3493
+ const mockPlugin = createMockPlugin();
3494
+
3495
+ const customPage: ProductChildCustomPage = {
3496
+ name: 'cost-analysis',
3497
+ label: 'Cost Analysis',
3498
+ component: { name: 'CostAnalysis' },
3499
+ };
3500
+
3501
+ const pluginProduct = new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, [customPage]);
3502
+
3503
+ expect(pluginProduct.newProduct).toBe(false);
3504
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
3505
+ });
3506
+
3507
+ it('should extend explorer with a group containing multiple pages', () => {
3508
+ const mockPlugin = createMockPlugin();
3509
+
3510
+ const costPage: ProductChildCustomPage = {
3511
+ name: 'cost-analysis',
3512
+ label: 'Cost Analysis',
3513
+ component: { name: 'CostAnalysis' },
3514
+ };
3515
+
3516
+ const usagePage: ProductChildCustomPage = {
3517
+ name: 'usage-report',
3518
+ label: 'Usage Report',
3519
+ component: { name: 'UsageReport' },
3520
+ };
3521
+
3522
+ const insightsGroup: ProductChildGroup = {
3523
+ name: 'insights',
3524
+ label: 'Insights',
3525
+ children: [costPage, usagePage],
3526
+ };
3527
+
3528
+ const pluginProduct = new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, [insightsGroup]);
3529
+
3530
+ expect(pluginProduct.newProduct).toBe(false);
3531
+ // 1 group parent route + 2 child routes = 3
3532
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(3);
3533
+ });
3534
+ });
3535
+
3536
+ describe('translation keys instead of labels', () => {
3537
+ it('should accept labelKey instead of label for product and pages', () => {
3538
+ const mockPlugin = createMockPlugin();
3539
+
3540
+ const product: ProductMetadata = {
3541
+ name: 'my-app',
3542
+ labelKey: 'product.myApp.label',
3543
+ icon: 'gear',
3544
+ };
3545
+
3546
+ const overviewPage: ProductChildCustomPage = {
3547
+ name: 'overview',
3548
+ labelKey: 'product.myApp.overview',
3549
+ component: { name: 'OverviewPage' },
3550
+ };
3551
+
3552
+ const pluginProduct = new PluginProduct(mockPlugin, product, [overviewPage]);
3553
+
3554
+ expect(pluginProduct.newProduct).toBe(true);
3555
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
3556
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
3557
+ });
3558
+
3559
+ it('should register labelKey on virtualType during apply', () => {
3560
+ const mockPlugin = createMockPlugin();
3561
+ const mockStore = createMockStore();
3562
+ const virtualTypeCalls: any[] = [];
3563
+ const mockDSL = {
3564
+ product: jest.fn(),
3565
+ basicType: jest.fn(),
3566
+ labelGroup: jest.fn(),
3567
+ setGroupDefaultType: jest.fn(),
3568
+ weightGroup: jest.fn(),
3569
+ virtualType: jest.fn((...args: any[]) => virtualTypeCalls.push(args)),
3570
+ configureType: jest.fn(),
3571
+ weightType: jest.fn(),
3572
+ };
3573
+
3574
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
3575
+
3576
+ const product: ProductMetadata = {
3577
+ name: 'my-app',
3578
+ labelKey: 'product.myApp.label',
3579
+ };
3580
+
3581
+ const overviewPage: ProductChildCustomPage = {
3582
+ name: 'overview',
3583
+ labelKey: 'product.myApp.overview',
3584
+ component: { name: 'OverviewPage' },
3585
+ };
3586
+
3587
+ const pluginProduct = new PluginProduct(mockPlugin, product, [overviewPage]);
3588
+
3589
+ pluginProduct.apply(mockPlugin, mockStore);
3590
+
3591
+ expect(virtualTypeCalls).toHaveLength(1);
3592
+ expect(virtualTypeCalls[0][0]).toStrictEqual(expect.objectContaining({ labelKey: 'product.myApp.overview' }));
3593
+ });
3594
+ });
3595
+
3596
+ describe('duplicate page name detection', () => {
3597
+ it('should throw when two custom pages have the same name in a new product', () => {
3598
+ const mockPlugin = createMockPlugin();
3599
+ const mockStore = createMockStore();
3600
+ const mockDSL = {
3601
+ product: jest.fn(),
3602
+ basicType: jest.fn(),
3603
+ labelGroup: jest.fn(),
3604
+ setGroupDefaultType: jest.fn(),
3605
+ weightGroup: jest.fn(),
3606
+ virtualType: jest.fn(),
3607
+ configureType: jest.fn(),
3608
+ weightType: jest.fn(),
3609
+ };
3610
+
3611
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
3612
+
3613
+ const page1: ProductChildCustomPage = {
3614
+ name: 'overview',
3615
+ label: 'Overview',
3616
+ component: { name: 'Page1' },
3617
+ };
3618
+
3619
+ const page2: ProductChildCustomPage = {
3620
+ name: 'overview',
3621
+ label: 'Overview Duplicate',
3622
+ component: { name: 'Page2' },
3623
+ };
3624
+
3625
+ const product: ProductMetadata = {
3626
+ name: 'my-app',
3627
+ label: 'My App',
3628
+ };
3629
+
3630
+ const pluginProduct = new PluginProduct(mockPlugin, product, [page1, page2]);
3631
+
3632
+ expect(() => {
3633
+ pluginProduct.apply(mockPlugin, mockStore);
3634
+ }).toThrow('Duplicate page name "overview"');
3635
+ });
3636
+
3637
+ it('should not throw when pages with the same name are in different groups (different resolved names)', () => {
3638
+ const mockPlugin = createMockPlugin();
3639
+ const mockStore = createMockStore();
3640
+ const mockDSL = {
3641
+ product: jest.fn(),
3642
+ basicType: jest.fn(),
3643
+ labelGroup: jest.fn(),
3644
+ setGroupDefaultType: jest.fn(),
3645
+ weightGroup: jest.fn(),
3646
+ virtualType: jest.fn(),
3647
+ configureType: jest.fn(),
3648
+ weightType: jest.fn(),
3649
+ };
3650
+
3651
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
3652
+
3653
+ // Same page name 'overview' but in different groups produces different resolved names
3654
+ const standalonePage: ProductChildCustomPage = {
3655
+ name: 'cost-analysis',
3656
+ label: 'Cost Analysis',
3657
+ component: { name: 'CostAnalysis1' },
3658
+ };
3659
+
3660
+ const groupChildPage: ProductChildCustomPage = {
3661
+ name: 'cost-analysis',
3662
+ label: 'Cost Analysis',
3663
+ component: { name: 'CostAnalysis2' },
3664
+ };
3665
+
3666
+ const group: ProductChildGroup = {
3667
+ name: 'insights',
3668
+ label: 'Insights',
3669
+ children: [groupChildPage],
3670
+ };
3671
+
3672
+ const product: ProductMetadata = {
3673
+ name: 'my-app',
3674
+ label: 'My App',
3675
+ };
3676
+
3677
+ const config: ProductChild[] = [standalonePage, group];
3678
+ const pluginProduct = new PluginProduct(mockPlugin, product, config);
3679
+
3680
+ // Different groups produce different resolved names (myapp-cost-analysis vs myapp-insights-cost-analysis)
3681
+ expect(() => {
3682
+ pluginProduct.apply(mockPlugin, mockStore);
3683
+ }).not.toThrow();
3684
+ });
3685
+
3686
+ it('should throw when two resource pages have the same type', () => {
3687
+ const mockPlugin = createMockPlugin();
3688
+ const mockStore = createMockStore();
3689
+ const mockDSL = {
3690
+ product: jest.fn(),
3691
+ basicType: jest.fn(),
3692
+ labelGroup: jest.fn(),
3693
+ setGroupDefaultType: jest.fn(),
3694
+ weightGroup: jest.fn(),
3695
+ virtualType: jest.fn(),
3696
+ configureType: jest.fn(),
3697
+ weightType: jest.fn(),
3698
+ };
3699
+
3700
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
3701
+
3702
+ const resource1: ProductChildResourcePage = { type: 'provisioning.cattle.io.cluster' };
3703
+
3704
+ const resource2: ProductChildResourcePage = { type: 'provisioning.cattle.io.cluster' };
3705
+
3706
+ const product: ProductMetadata = {
3707
+ name: 'my-app',
3708
+ label: 'My App',
3709
+ };
3710
+
3711
+ const pluginProduct = new PluginProduct(mockPlugin, product, [resource1, resource2]);
3712
+
3713
+ expect(() => {
3714
+ pluginProduct.apply(mockPlugin, mockStore);
3715
+ }).toThrow('Duplicate resource type "provisioning.cattle.io.cluster"');
3716
+ });
3717
+
3718
+ it('should not throw when pages have different names', () => {
3719
+ const mockPlugin = createMockPlugin();
3720
+ const mockStore = createMockStore();
3721
+ const mockDSL = {
3722
+ product: jest.fn(),
3723
+ basicType: jest.fn(),
3724
+ labelGroup: jest.fn(),
3725
+ setGroupDefaultType: jest.fn(),
3726
+ weightGroup: jest.fn(),
3727
+ virtualType: jest.fn(),
3728
+ configureType: jest.fn(),
3729
+ weightType: jest.fn(),
3730
+ };
3731
+
3732
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
3733
+
3734
+ const page1: ProductChildCustomPage = {
3735
+ name: 'overview',
3736
+ label: 'Overview',
3737
+ component: { name: 'Page1' },
3738
+ };
3739
+
3740
+ const page2: ProductChildCustomPage = {
3741
+ name: 'settings',
3742
+ label: 'Settings',
3743
+ component: { name: 'Page2' },
3744
+ };
3745
+
3746
+ const product: ProductMetadata = {
3747
+ name: 'my-app',
3748
+ label: 'My App',
3749
+ };
3750
+
3751
+ const pluginProduct = new PluginProduct(mockPlugin, product, [page1, page2]);
3752
+
3753
+ expect(() => {
3754
+ pluginProduct.apply(mockPlugin, mockStore);
3755
+ }).not.toThrow();
3756
+ });
3757
+ });
3758
+
3759
+ describe('group with component route naming', () => {
3760
+ it('should include the group name in the virtualType route for side-menu highlighting', () => {
3761
+ const mockPlugin = createMockPlugin();
3762
+ const mockStore = createMockStore();
3763
+ const virtualTypeCalls: any[] = [];
3764
+ const mockDSL = {
3765
+ product: jest.fn(),
3766
+ basicType: jest.fn(),
3767
+ labelGroup: jest.fn(),
3768
+ setGroupDefaultType: jest.fn(),
3769
+ weightGroup: jest.fn(),
3770
+ virtualType: jest.fn((...args: any[]) => virtualTypeCalls.push(args)),
3771
+ configureType: jest.fn(),
3772
+ weightType: jest.fn(),
3773
+ };
3774
+
3775
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
3776
+
3777
+ const childPage: ProductChildCustomPage = {
3778
+ name: 'alerts',
3779
+ label: 'Alerts',
3780
+ component: { name: 'AlertsPage' },
3781
+ };
3782
+
3783
+ const monitoringGroup: ProductChildGroup = {
3784
+ name: 'monitoring',
3785
+ label: 'Monitoring',
3786
+ component: { name: 'MonitoringOverview' },
3787
+ children: [childPage],
3788
+ };
3789
+
3790
+ const product: ProductMetadata = {
3791
+ name: 'my-app',
3792
+ label: 'My App',
3793
+ };
3794
+
3795
+ const pluginProduct = new PluginProduct(mockPlugin, product, [monitoringGroup]);
3796
+
3797
+ pluginProduct.apply(mockPlugin, mockStore);
3798
+
3799
+ // Find the virtualType call for the group (has exact + overview flags)
3800
+ const groupVirtualType = virtualTypeCalls.find((call) => call[0].exact === true && call[0].overview === true);
3801
+
3802
+ expect(groupVirtualType).toBeDefined();
3803
+ // The route name should contain the group name 'monitoring', not a generic 'group'
3804
+ expect(groupVirtualType[0].route.name).toStrictEqual(expect.stringContaining('monitoring'));
3805
+ // It should NOT be a generic route without the group name
3806
+ expect(groupVirtualType[0].route.name).not.toBe('myapp-c-cluster');
3807
+ });
3808
+ });
3809
+ });
3219
3810
  });