@rancher/shell 3.0.12-rc.5 → 3.0.12-rc.6

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 (89) hide show
  1. package/apis/intf/resources-api/resource-base.ts +42 -2
  2. package/apis/intf/resources-api/resource-instance.ts +101 -0
  3. package/apis/intf/resources-api/resources-api.ts +177 -23
  4. package/apis/intf/resources.ts +8 -6
  5. package/apis/resources/__tests__/resources-api-class.test.ts +309 -28
  6. package/apis/resources/resources-api-class.ts +232 -43
  7. package/assets/images/providers/traefik.png +0 -0
  8. package/assets/translations/en-us.yaml +10 -4
  9. package/chart/__tests__/rancher-monitoring-dashboards-index.test.ts +269 -0
  10. package/chart/rancher-monitoring-dashboards/index.vue +155 -0
  11. package/cloud-credential/__tests__/generic.test.ts +132 -0
  12. package/cloud-credential/generic.vue +33 -3
  13. package/components/EtcdInfoBanner.vue +2 -12
  14. package/components/GrafanaDashboard.vue +2 -8
  15. package/components/Resource/Detail/Masthead/__tests__/index.test.ts +68 -14
  16. package/components/Resource/Detail/Masthead/index.vue +8 -3
  17. package/components/form/Security.vue +1 -1
  18. package/components/formatter/Translate.vue +22 -1
  19. package/components/nav/Group.vue +5 -10
  20. package/components/nav/TopLevelMenu.vue +1 -30
  21. package/components/nav/__tests__/Group.test.ts +61 -0
  22. package/config/labels-annotations.js +1 -0
  23. package/config/table-headers.js +5 -2
  24. package/core/__tests__/plugin-products-apply.test.ts +787 -0
  25. package/core/__tests__/plugin-products-extend.test.ts +189 -0
  26. package/core/__tests__/plugin-products-helpers.test.ts +81 -78
  27. package/core/__tests__/plugin-products-new.test.ts +389 -0
  28. package/core/__tests__/plugin-products-scenarios.test.ts +980 -0
  29. package/core/__tests__/plugin-products-side-menu.test.ts +1935 -0
  30. package/core/plugin-products-base.ts +201 -93
  31. package/core/plugin-products-extending.ts +1 -1
  32. package/core/plugin-products-external.ts +380 -0
  33. package/core/plugin-products-helpers.ts +28 -24
  34. package/core/plugin-products-internal.ts +207 -0
  35. package/core/plugin-products-top-level.ts +10 -5
  36. package/core/plugin-products-type-guards.ts +17 -6
  37. package/core/plugin-products.ts +5 -7
  38. package/core/plugin-types.ts +1 -389
  39. package/core/plugin.ts +10 -11
  40. package/core/types.ts +6 -202
  41. package/detail/__tests__/provisioning.cattle.io.cluster.test.ts +52 -0
  42. package/detail/pod.vue +1 -1
  43. package/detail/provisioning.cattle.io.cluster.vue +0 -2
  44. package/edit/auth/AuthProviderWarningBanners.vue +12 -0
  45. package/edit/auth/__tests__/AuthProviderWarningBanners.test.ts +10 -1
  46. package/edit/auth/__tests__/azuread.test.ts +1 -0
  47. package/edit/auth/__tests__/github.test.ts +1 -0
  48. package/edit/auth/__tests__/oidc.test.ts +1 -0
  49. package/edit/auth/__tests__/saml.test.ts +1 -0
  50. package/edit/auth/ldap/__tests__/index.test.ts +1 -0
  51. package/edit/provisioning.cattle.io.cluster/index.vue +1 -1
  52. package/edit/provisioning.cattle.io.cluster/rke2.vue +1 -1
  53. package/edit/provisioning.cattle.io.cluster/tabs/networking/index.vue +2 -2
  54. package/list/management.cattle.io.user.vue +24 -5
  55. package/list/utils/management.cattle.io.cluster.utils.ts +1 -1
  56. package/machine-config/__tests__/generic.test.ts +163 -0
  57. package/machine-config/components/EC2Networking.vue +31 -7
  58. package/machine-config/components/__tests__/EC2Networking.test.ts +38 -15
  59. package/machine-config/generic.vue +32 -5
  60. package/mixins/resource-fetch.js +1 -1
  61. package/models/__tests__/rke-machine.cattle.io.vmwarevspheremachinetemplate.test.ts +22 -0
  62. package/models/pod.js +12 -10
  63. package/models/rke-machine.cattle.io.vmwarevspheremachinetemplate.js +9 -0
  64. package/package.json +1 -1
  65. package/pages/auth/login.vue +1 -1
  66. package/pages/c/_cluster/apps/charts/__tests__/install.test.ts +110 -0
  67. package/pages/c/_cluster/apps/charts/install.vue +29 -0
  68. package/pages/c/_cluster/auth/config/index.vue +11 -4
  69. package/pages/c/_cluster/monitoring/index.vue +6 -1
  70. package/plugins/dashboard-store/resource-class.js +10 -3
  71. package/plugins/steve/__tests__/steve-class-resource-api.test.ts +147 -0
  72. package/plugins/steve/steve-class.js +43 -0
  73. package/plugins/steve/subscribe.js +11 -0
  74. package/rancher-components/Form/LabeledInput/LabeledInput.test.ts +115 -0
  75. package/rancher-components/Form/LabeledInput/LabeledInput.vue +83 -3
  76. package/store/__tests__/auth.test.ts +18 -0
  77. package/store/__tests__/notifications.test.ts +466 -1
  78. package/store/__tests__/ui-context.test.ts +255 -0
  79. package/store/auth.js +11 -1
  80. package/store/features.js +1 -0
  81. package/store/plugins.js +6 -0
  82. package/types/rancher/steve.api.ts +2 -2
  83. package/types/shell/index.d.ts +58 -0
  84. package/types/store/dashboard-store.types.ts +2 -2
  85. package/types/store/type-map.ts +262 -1
  86. package/utils/async.ts +2 -0
  87. package/utils/grafana.js +2 -17
  88. package/utils/monitoring.js +38 -1
  89. package/core/__tests__/plugin-products.test.ts +0 -4694
@@ -0,0 +1,980 @@
1
+ import { PluginProduct } from '@shell/core/plugin-products';
2
+ import { IExtension } from '@shell/core/types';
3
+ import {
4
+ ProductChild,
5
+ ProductChildCustomPage, ProductChildGroup, ProductChildPage, ProductChildResourcePage, ProductMetadata, ProductMetadataSinglePage, StandardProductNames
6
+ } from '@shell/core/plugin-products-external';
7
+
8
+ // Mock the helper functions
9
+ jest.mock('@shell/core/plugin-products-helpers', () => ({
10
+ gatherChildrenOrdering: jest.fn((config) => config),
11
+ generateTopLevelExtensionSimpleBaseRoute: jest.fn((name, opts) => ({
12
+ name: `${ name }-simple`,
13
+ path: opts?.omitPath ? '' : `/${ name }`,
14
+ component: opts?.component,
15
+ })),
16
+ generateVirtualTypeRoute: jest.fn((parentName, childName, opts) => ({
17
+ name: childName ? `${ parentName }-${ childName }` : `${ parentName }-group`,
18
+ path: opts?.omitPath ? '' : `/${ parentName }/${ childName || 'group' }`,
19
+ component: opts?.component,
20
+ })),
21
+ generateConfigureTypeRoute: jest.fn((parentName, page, opts) => {
22
+ const routeName = opts?.extendProduct ? `c-cluster-${ parentName }-resource` : `${ parentName }-c-cluster-resource`;
23
+ const routePath = opts?.extendProduct ? `c/:cluster/${ parentName }/:resource` : `${ parentName }/c/:cluster/:resource`;
24
+ const cluster = opts?.extendProduct ? undefined : '__BLANK_CLUSTER__';
25
+
26
+ return {
27
+ name: routeName,
28
+ path: opts?.omitPath ? '' : routePath,
29
+ params: cluster ? {
30
+ product: parentName.replace(/-/g, ''),
31
+ cluster,
32
+ resource: page?.type,
33
+ } : {
34
+ product: parentName,
35
+ resource: page?.type,
36
+ },
37
+ };
38
+ }),
39
+ generateResourceRoutes: jest.fn((parentName, child) => [
40
+ {
41
+ name: `${ parentName }-${ child.type }-list`,
42
+ path: `/${ parentName }/${ child.type }`,
43
+ },
44
+ {
45
+ name: `${ parentName }-${ child.type }-detail`,
46
+ path: `/${ parentName }/${ child.type }/:id`,
47
+ },
48
+ ]),
49
+ }));
50
+
51
+ jest.mock('@shell/core/productDebugger', () => ({
52
+ DSLRegistrationsPerProduct: jest.fn(),
53
+ registeredRoutes: jest.fn(),
54
+ }));
55
+
56
+ // Create mock factories
57
+ function createMockPlugin(): IExtension {
58
+ return {
59
+ _registerTopLevelProduct: jest.fn(),
60
+ addRoute: jest.fn(),
61
+ enableServerSidePagination: jest.fn(),
62
+ DSL: jest.fn(),
63
+ } as any as IExtension;
64
+ }
65
+
66
+ function createMockStore(extendableProducts: string[] = Object.values(StandardProductNames), managementSchemas: string[] = []): any {
67
+ return {
68
+ getters: {
69
+ 'type-map/productByName': (productName: string) => (extendableProducts.includes(productName) ? { name: productName, extendable: true } : undefined),
70
+ 'management/schemaFor': (type: string) => (managementSchemas.includes(type) ? { id: type } : undefined),
71
+ }
72
+ };
73
+ }
74
+
75
+ describe('pluginProduct', () => {
76
+ describe('real-world scenarios from pkg/add-new-prod', () => {
77
+ describe('scenario 1: simple product with single page component (plain layout)', () => {
78
+ it('should create single page product with plain layout', () => {
79
+ const mockPlugin = createMockPlugin();
80
+ const productSinglePage: ProductMetadataSinglePage = {
81
+ name: 'alex-simple-one-page',
82
+ sideBar: { weight: -100, icon: { name: 'aaa' } },
83
+ label: 'Simple One Page (no sidebar)',
84
+ component: { name: 'TestComponent' },
85
+ };
86
+
87
+ const pluginProduct = new PluginProduct(mockPlugin, productSinglePage, []);
88
+
89
+ expect(pluginProduct.newProduct).toBe(true);
90
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
91
+ });
92
+ });
93
+
94
+ describe('scenario 2: simple product without children', () => {
95
+ it('should create product with sidebar but no config children', () => {
96
+ const mockPlugin = createMockPlugin();
97
+ const productMetadata: ProductMetadata = {
98
+ name: 'alex-simple-top-level',
99
+ sideBar: { weight: -100, icon: { name: 'aaa' } },
100
+ label: 'Simple (with sidebar)',
101
+ };
102
+
103
+ const pluginProduct = new PluginProduct(mockPlugin, productMetadata, []);
104
+
105
+ expect(pluginProduct.newProduct).toBe(true);
106
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
107
+ });
108
+ });
109
+
110
+ describe('scenario 3: simple product with simple children (virtualTypes)', () => {
111
+ it('should create product with multiple simple virtualType children', () => {
112
+ const mockPlugin = createMockPlugin();
113
+ const mockStore = createMockStore();
114
+ const mockDSL = {
115
+ product: jest.fn(),
116
+ basicType: jest.fn(),
117
+ labelGroup: jest.fn(),
118
+ setGroupDefaultType: jest.fn(),
119
+ weightGroup: jest.fn(),
120
+ virtualType: jest.fn(),
121
+ configureType: jest.fn(),
122
+ weightType: jest.fn(),
123
+ };
124
+
125
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
126
+
127
+ const productMetadata: ProductMetadata = {
128
+ name: 'alex-simple-children',
129
+ sideBar: { weight: -100, icon: { name: 'aaa' } },
130
+ label: 'Simple with Children',
131
+ };
132
+ const config: ProductChildPage[] = [
133
+ {
134
+ name: 'page1',
135
+ label: 'My label for page 1',
136
+ component: { name: 'TestComponent' },
137
+ sideMenu: { weight: 1 },
138
+ },
139
+ {
140
+ name: 'page2',
141
+ label: 'My label for page 2',
142
+ component: { name: 'TestComponent' },
143
+ sideMenu: { weight: 2 },
144
+ },
145
+ ];
146
+
147
+ const pluginProduct = new PluginProduct(mockPlugin, productMetadata, config);
148
+
149
+ pluginProduct.apply(mockPlugin, mockStore);
150
+
151
+ expect(mockDSL.virtualType).toHaveBeenCalledTimes(2);
152
+ expect(mockDSL.product).toHaveBeenCalledTimes(1);
153
+ });
154
+ });
155
+
156
+ describe('scenario 4: product with simple children (virtualTypes) + type (configureType)', () => {
157
+ it('should handle mix of virtualType pages and resource types', () => {
158
+ const mockPlugin = createMockPlugin();
159
+ const mockStore = createMockStore();
160
+ const mockDSL = {
161
+ product: jest.fn(),
162
+ basicType: jest.fn(),
163
+ labelGroup: jest.fn(),
164
+ setGroupDefaultType: jest.fn(),
165
+ weightGroup: jest.fn(),
166
+ virtualType: jest.fn(),
167
+ configureType: jest.fn(),
168
+ weightType: jest.fn(),
169
+ };
170
+
171
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
172
+
173
+ const productMetadata: ProductMetadata = {
174
+ name: 'alex-simple-children',
175
+ sideBar: { weight: -100, icon: { name: 'aaa' } },
176
+ label: 'Simple with Children',
177
+ };
178
+ const config: ProductChildPage[] = [
179
+ {
180
+ name: 'page1',
181
+ label: 'My label for page 1',
182
+ component: { name: 'TestComponent' },
183
+ sideMenu: { weight: 1 },
184
+ },
185
+ {
186
+ name: 'page2',
187
+ label: 'My label for page 2',
188
+ component: { name: 'TestComponent' },
189
+ sideMenu: { weight: 2 },
190
+ },
191
+ { type: 'upgrade.cattle.io.plan', sideMenu: { weight: 3 } },
192
+ ];
193
+
194
+ const pluginProduct = new PluginProduct(mockPlugin, productMetadata, config);
195
+
196
+ pluginProduct.apply(mockPlugin, mockStore);
197
+
198
+ expect(mockDSL.virtualType).toHaveBeenCalledTimes(2);
199
+ expect(mockDSL.configureType).toHaveBeenCalledWith('upgrade.cattle.io.plan', expect.any(Object));
200
+ });
201
+ });
202
+
203
+ describe('scenario 5: product with type first, then children with nested groups', () => {
204
+ it('should handle resource type first, then virtualType pages with nested children', () => {
205
+ const mockPlugin = createMockPlugin();
206
+ const mockStore = createMockStore();
207
+ const mockDSL = {
208
+ product: jest.fn(),
209
+ basicType: jest.fn(),
210
+ labelGroup: jest.fn(),
211
+ setGroupDefaultType: jest.fn(),
212
+ weightGroup: jest.fn(),
213
+ virtualType: jest.fn(),
214
+ configureType: jest.fn(),
215
+ weightType: jest.fn(),
216
+ };
217
+
218
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
219
+
220
+ const productMetadata: ProductMetadata = {
221
+ name: 'alex-simple-children',
222
+ sideBar: { weight: -100, icon: { name: 'aaa' } },
223
+ label: 'Simple with Children',
224
+ };
225
+ const config: (ProductChildGroup | ProductChildPage)[] = [
226
+ { type: 'fleet.cattle.io.clustergroup', sideMenu: { weight: 1 } },
227
+ {
228
+ name: 'page1',
229
+ label: 'My label for page 1',
230
+ component: { name: 'TestComponent' },
231
+ sideMenu: {
232
+ weight: 2,
233
+ children: [
234
+ {
235
+ name: 'hello0',
236
+ label: 'Testing 12',
237
+ labelKey: 'aks.label',
238
+ component: { name: 'TestComponent' },
239
+ },
240
+ {
241
+ name: 'hello1',
242
+ label: 'Testing 1',
243
+ labelKey: 'aks.label',
244
+ component: { name: 'TestComponent' },
245
+ },
246
+ {
247
+ name: 'hello3',
248
+ labelKey: 'aks.label',
249
+ component: { name: 'TestComponent' },
250
+ },
251
+ {
252
+ name: 'hello2',
253
+ label: 'Testing 2',
254
+ component: { name: 'TestComponent' },
255
+ },
256
+ ],
257
+ },
258
+ },
259
+ { type: 'upgrade.cattle.io.plan', sideMenu: { weight: 3 } },
260
+ {
261
+ name: 'page2',
262
+ label: 'My label for page 2',
263
+ component: { name: 'TestComponent' },
264
+ sideMenu: { weight: 4 },
265
+ },
266
+ ];
267
+
268
+ const pluginProduct = new PluginProduct(mockPlugin, productMetadata, config);
269
+
270
+ pluginProduct.apply(mockPlugin, mockStore);
271
+
272
+ expect(mockDSL.configureType).toHaveBeenCalledWith('fleet.cattle.io.clustergroup', expect.any(Object));
273
+ expect(mockDSL.virtualType).toHaveBeenCalledTimes(6);
274
+ expect(mockDSL.labelGroup).toHaveBeenCalledTimes(1);
275
+ expect(mockDSL.product).toHaveBeenCalledTimes(1);
276
+ });
277
+ });
278
+
279
+ describe('scenario 6: extend standard product without configuring children', () => {
280
+ it('should extend existing standard product with empty config', () => {
281
+ const mockPlugin = createMockPlugin();
282
+ const validStandardProduct = StandardProductNames.EXPLORER;
283
+
284
+ const pluginProduct = new PluginProduct(mockPlugin, validStandardProduct, []);
285
+
286
+ expect(pluginProduct.newProduct).toBe(false);
287
+ expect(mockPlugin._registerTopLevelProduct).not.toHaveBeenCalled();
288
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
289
+ });
290
+ });
291
+
292
+ describe('scenario 7: extend standard product with simple virtualType children', () => {
293
+ it('should extend standard product adding simple virtualType page', () => {
294
+ const mockPlugin = createMockPlugin();
295
+ const mockStore = createMockStore();
296
+ const mockDSL = {
297
+ product: jest.fn(),
298
+ basicType: jest.fn(),
299
+ labelGroup: jest.fn(),
300
+ setGroupDefaultType: jest.fn(),
301
+ weightGroup: jest.fn(),
302
+ virtualType: jest.fn(),
303
+ configureType: jest.fn(),
304
+ weightType: jest.fn(),
305
+ };
306
+
307
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
308
+
309
+ const validStandardProduct = StandardProductNames.EXPLORER;
310
+ const config: ProductChildPage[] = [
311
+ {
312
+ name: 'mysettings',
313
+ label: 'Custom',
314
+ component: { name: 'TestComponent' },
315
+ sideMenu: { weight: 97 },
316
+ },
317
+ ];
318
+
319
+ const pluginProduct = new PluginProduct(mockPlugin, validStandardProduct, config);
320
+
321
+ pluginProduct.apply(mockPlugin, mockStore);
322
+
323
+ expect(mockDSL.virtualType).toHaveBeenCalledTimes(1);
324
+ expect(mockDSL.product).not.toHaveBeenCalled();
325
+ });
326
+ });
327
+
328
+ describe('scenario 8: extend standard product with mixed types and nested groups', () => {
329
+ it('should extend standard product adding mixed virtualTypes and resource types with nested groups', () => {
330
+ const mockPlugin = createMockPlugin();
331
+ const mockStore = createMockStore();
332
+ const mockDSL = {
333
+ product: jest.fn(),
334
+ basicType: jest.fn(),
335
+ labelGroup: jest.fn(),
336
+ setGroupDefaultType: jest.fn(),
337
+ weightGroup: jest.fn(),
338
+ virtualType: jest.fn(),
339
+ configureType: jest.fn(),
340
+ weightType: jest.fn(),
341
+ };
342
+
343
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
344
+
345
+ const validStandardProduct = StandardProductNames.EXPLORER;
346
+ const config: (ProductChildGroup | ProductChildPage)[] = [
347
+ {
348
+ name: 'page1',
349
+ label: 'My label for page 1',
350
+ sideMenu: {
351
+ weight: -10,
352
+ children: [
353
+ {
354
+ name: 'hello0',
355
+ label: 'Testing 12',
356
+ labelKey: 'aks.label',
357
+ component: { name: 'TestComponent' },
358
+ },
359
+ {
360
+ name: 'hello1',
361
+ label: 'Testing 1',
362
+ component: { name: 'TestComponent' },
363
+ },
364
+ {
365
+ name: 'hello3',
366
+ labelKey: 'generic.unified',
367
+ component: { name: 'TestComponent' },
368
+ },
369
+ {
370
+ name: 'hello2',
371
+ label: 'Testing 2',
372
+ component: { name: 'TestComponent' },
373
+ },
374
+ ],
375
+ },
376
+ },
377
+ { type: 'upgrade.cattle.io.plan', sideMenu: { weight: 1 } },
378
+ {
379
+ name: 'page2',
380
+ label: 'My label for page 2',
381
+ component: { name: 'TestComponent' },
382
+ sideMenu: { weight: 2 },
383
+ },
384
+ ];
385
+
386
+ const pluginProduct = new PluginProduct(mockPlugin, validStandardProduct, config);
387
+
388
+ pluginProduct.apply(mockPlugin, mockStore);
389
+
390
+ expect(mockDSL.virtualType).toHaveBeenCalledTimes(5);
391
+ expect(mockDSL.configureType).toHaveBeenCalledWith('upgrade.cattle.io.plan', expect.any(Object));
392
+ expect(mockDSL.labelGroup).toHaveBeenCalledTimes(1);
393
+ expect(mockDSL.product).not.toHaveBeenCalled();
394
+ });
395
+ });
396
+ });
397
+
398
+ describe('documentation examples', () => {
399
+ describe('quick start: string convenience method', () => {
400
+ it('should create a new product from just a string name', () => {
401
+ const mockPlugin = createMockPlugin();
402
+ const pluginProduct = PluginProduct.fromName(mockPlugin, 'my-first-product');
403
+
404
+ expect(pluginProduct.newProduct).toBe(true);
405
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
406
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
407
+ });
408
+ });
409
+
410
+ describe('single page product', () => {
411
+ it('should create a product with a single page component and no config', () => {
412
+ const mockPlugin = createMockPlugin();
413
+ const product: ProductMetadataSinglePage = {
414
+ name: 'my-dashboard',
415
+ label: 'My Dashboard',
416
+ component: { name: 'DashboardPage' },
417
+ sideBar: { icon: { name: 'globe' } },
418
+ };
419
+
420
+ const pluginProduct = new PluginProduct(mockPlugin, product, []);
421
+
422
+ expect(pluginProduct.newProduct).toBe(true);
423
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
424
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
425
+ });
426
+ });
427
+
428
+ describe('product with custom pages', () => {
429
+ it('should register routes for each custom page', () => {
430
+ const mockPlugin = createMockPlugin();
431
+
432
+ const overviewPage: ProductChildCustomPage = {
433
+ name: 'overview',
434
+ label: 'Overview',
435
+ component: { name: 'OverviewPage' },
436
+ sideMenu: { weight: 2 },
437
+ };
438
+
439
+ const settingsPage: ProductChildCustomPage = {
440
+ name: 'settings',
441
+ label: 'Settings',
442
+ component: { name: 'SettingsPage' },
443
+ sideMenu: { weight: 1 },
444
+ };
445
+
446
+ const product: ProductMetadata = {
447
+ name: 'my-app',
448
+ label: 'My App',
449
+ sideBar: { icon: { name: 'gear' } }
450
+ };
451
+
452
+ const pluginProduct = new PluginProduct(mockPlugin, product, [overviewPage, settingsPage]);
453
+
454
+ expect(pluginProduct.newProduct).toBe(true);
455
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
456
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(2);
457
+ });
458
+ });
459
+
460
+ describe('product with resource pages', () => {
461
+ it('should register resource CRUD routes for resource page items', () => {
462
+ const mockPlugin = createMockPlugin();
463
+
464
+ const clusterPage: ProductChildResourcePage = {
465
+ type: 'provisioning.cattle.io.cluster',
466
+ label: 'Clusters',
467
+ sideMenu: { weight: 2 },
468
+ };
469
+
470
+ const nodePage: ProductChildResourcePage = {
471
+ type: 'management.cattle.io.node',
472
+ sideMenu: { weight: 1 },
473
+ };
474
+
475
+ const product: ProductMetadata = {
476
+ name: 'my-resources',
477
+ label: 'My Resources',
478
+ };
479
+
480
+ const pluginProduct = new PluginProduct(mockPlugin, product, [clusterPage, nodePage]);
481
+
482
+ expect(pluginProduct.newProduct).toBe(true);
483
+ // Resource routes are only added once (shared CRUD routes) - mock generates list + detail = 2
484
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(2);
485
+ });
486
+ });
487
+
488
+ describe('product with groups', () => {
489
+ it('should register routes for a product with groups and standalone pages', () => {
490
+ const mockPlugin = createMockPlugin();
491
+
492
+ const alertsPage: ProductChildCustomPage = {
493
+ name: 'alerts',
494
+ label: 'Alerts',
495
+ component: { name: 'AlertsPage' },
496
+ };
497
+
498
+ const metricsPage: ProductChildCustomPage = {
499
+ name: 'metrics',
500
+ label: 'Metrics',
501
+ component: { name: 'MetricsPage' },
502
+ };
503
+
504
+ const monitoringGroup: ProductChildGroup = {
505
+ name: 'monitoring',
506
+ label: 'Monitoring',
507
+ sideMenu: {
508
+ weight: 2,
509
+ children: [alertsPage, metricsPage],
510
+ },
511
+ };
512
+
513
+ const overviewPage: ProductChildCustomPage = {
514
+ name: 'overview',
515
+ label: 'Overview',
516
+ component: { name: 'OverviewPage' },
517
+ sideMenu: { weight: 3 },
518
+ };
519
+
520
+ const product: ProductMetadata = {
521
+ name: 'my-platform',
522
+ label: 'My Platform',
523
+ };
524
+
525
+ const config: ProductChild[] = [overviewPage, monitoringGroup];
526
+ const pluginProduct = new PluginProduct(mockPlugin, product, config);
527
+
528
+ expect(pluginProduct.newProduct).toBe(true);
529
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
530
+ // 1 standalone page + 1 group parent route + 2 group children routes = 4
531
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(4);
532
+ });
533
+ });
534
+
535
+ describe('extending an existing product', () => {
536
+ it('should extend explorer with a custom page', () => {
537
+ const mockPlugin = createMockPlugin();
538
+
539
+ const customPage: ProductChildCustomPage = {
540
+ name: 'my-custom-view',
541
+ label: 'My Custom View',
542
+ component: { name: 'MyCustomView' },
543
+ };
544
+
545
+ const pluginProduct = new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, [customPage]);
546
+
547
+ expect(pluginProduct.newProduct).toBe(false);
548
+ expect(mockPlugin._registerTopLevelProduct).not.toHaveBeenCalled();
549
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
550
+ });
551
+ });
552
+
553
+ describe('mixed pages: custom + resource', () => {
554
+ it('should register routes for both custom pages and resource pages together', () => {
555
+ const mockPlugin = createMockPlugin();
556
+
557
+ const product: ProductMetadata = {
558
+ name: 'my-platform',
559
+ label: 'My Platform',
560
+ };
561
+
562
+ const config: ProductChild[] = [
563
+ {
564
+ name: 'dashboard',
565
+ label: 'Dashboard',
566
+ component: { name: 'Dashboard' },
567
+ sideMenu: { weight: 3 },
568
+ },
569
+ { type: 'provisioning.cattle.io.cluster', sideMenu: { weight: 2 } },
570
+ {
571
+ name: 'settings',
572
+ label: 'Settings',
573
+ component: { name: 'Settings' },
574
+ sideMenu: { weight: 1 },
575
+ },
576
+ ];
577
+ const pluginProduct = new PluginProduct(mockPlugin, product, config);
578
+
579
+ expect(pluginProduct.newProduct).toBe(true);
580
+ // 2 custom page routes + resource CRUD routes (list + detail = 2 from mock) = 4
581
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(4);
582
+ // Verify addRoute was called for both custom pages and resource routes
583
+ const routeCalls = (mockPlugin.addRoute as jest.Mock).mock.calls;
584
+ const hasCustomRoutes = routeCalls.some((call) => call[0]?.name?.includes('dashboard'));
585
+ const hasResourceRoutes = routeCalls.some((call) => call[0]?.name?.includes('provisioning.cattle.io.cluster'));
586
+
587
+ expect(hasCustomRoutes).toBe(true);
588
+ expect(hasResourceRoutes).toBe(true);
589
+ });
590
+ });
591
+
592
+ describe('group with its own page', () => {
593
+ it('should register a route for the group page itself and its children', () => {
594
+ const mockPlugin = createMockPlugin();
595
+
596
+ const alertsPage: ProductChildCustomPage = {
597
+ name: 'alerts',
598
+ label: 'Alerts',
599
+ component: { name: 'AlertsPage' },
600
+ };
601
+
602
+ const metricsPage: ProductChildCustomPage = {
603
+ name: 'metrics',
604
+ label: 'Metrics',
605
+ component: { name: 'MetricsPage' },
606
+ };
607
+
608
+ const monitoringGroup: ProductChildGroup = {
609
+ name: 'monitoring',
610
+ label: 'Monitoring',
611
+ component: { name: 'MonitoringOverview' },
612
+ sideMenu: { children: [alertsPage, metricsPage] },
613
+ };
614
+
615
+ const product: ProductMetadata = {
616
+ name: 'my-platform',
617
+ label: 'My Platform',
618
+ };
619
+
620
+ const pluginProduct = new PluginProduct(mockPlugin, product, [monitoringGroup]);
621
+
622
+ expect(pluginProduct.newProduct).toBe(true);
623
+ // 1 group parent route (with component) + 2 children routes = 3
624
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(3);
625
+ });
626
+
627
+ it('should generate a route with the group name for proper side-menu highlighting', () => {
628
+ const mockPlugin = createMockPlugin();
629
+
630
+ const childPage: ProductChildCustomPage = {
631
+ name: 'child',
632
+ label: 'Child',
633
+ component: { name: 'ChildComponent' },
634
+ };
635
+
636
+ const monitoringGroup: ProductChildGroup = {
637
+ name: 'monitoring',
638
+ label: 'Monitoring',
639
+ component: { name: 'MonitoringOverview' },
640
+ sideMenu: { children: [childPage] },
641
+ };
642
+
643
+ const product: ProductMetadata = {
644
+ name: 'my-platform',
645
+ label: 'My Platform',
646
+ };
647
+
648
+ new PluginProduct(mockPlugin, product, [monitoringGroup]);
649
+
650
+ // The group's own route should include the group name in the route name
651
+ // This ensures the side-menu can highlight the correct item
652
+ const routeCalls = (mockPlugin.addRoute as jest.Mock).mock.calls;
653
+ const groupRoute = routeCalls.find((call) => call[0]?.name?.includes('monitoring'));
654
+
655
+ expect(groupRoute).toBeDefined();
656
+ expect(groupRoute[0].name).toStrictEqual(expect.stringContaining('monitoring'));
657
+ });
658
+ });
659
+
660
+ describe('extending Cluster Explorer', () => {
661
+ it('should extend explorer with a standalone page', () => {
662
+ const mockPlugin = createMockPlugin();
663
+
664
+ const customPage: ProductChildCustomPage = {
665
+ name: 'cost-analysis',
666
+ label: 'Cost Analysis',
667
+ component: { name: 'CostAnalysis' },
668
+ };
669
+
670
+ const pluginProduct = new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, [customPage]);
671
+
672
+ expect(pluginProduct.newProduct).toBe(false);
673
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
674
+ });
675
+
676
+ it('should extend explorer with a group containing multiple pages', () => {
677
+ const mockPlugin = createMockPlugin();
678
+
679
+ const costPage: ProductChildCustomPage = {
680
+ name: 'cost-analysis',
681
+ label: 'Cost Analysis',
682
+ component: { name: 'CostAnalysis' },
683
+ };
684
+
685
+ const usagePage: ProductChildCustomPage = {
686
+ name: 'usage-report',
687
+ label: 'Usage Report',
688
+ component: { name: 'UsageReport' },
689
+ };
690
+
691
+ const insightsGroup: ProductChildGroup = {
692
+ name: 'insights',
693
+ label: 'Insights',
694
+ sideMenu: { children: [costPage, usagePage] },
695
+ };
696
+
697
+ const pluginProduct = new PluginProduct(mockPlugin, StandardProductNames.EXPLORER, [insightsGroup]);
698
+
699
+ expect(pluginProduct.newProduct).toBe(false);
700
+ // 1 group parent route + 2 child routes = 3
701
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(3);
702
+ });
703
+ });
704
+
705
+ describe('translation keys instead of labels', () => {
706
+ it('should accept labelKey instead of label for product and pages', () => {
707
+ const mockPlugin = createMockPlugin();
708
+
709
+ const product: ProductMetadata = {
710
+ name: 'my-app',
711
+ labelKey: 'product.myApp.label',
712
+ sideBar: { icon: { name: 'gear' } }
713
+ };
714
+
715
+ const overviewPage: ProductChildCustomPage = {
716
+ name: 'overview',
717
+ labelKey: 'product.myApp.overview',
718
+ component: { name: 'OverviewPage' },
719
+ };
720
+
721
+ const pluginProduct = new PluginProduct(mockPlugin, product, [overviewPage]);
722
+
723
+ expect(pluginProduct.newProduct).toBe(true);
724
+ expect(mockPlugin._registerTopLevelProduct).toHaveBeenCalledTimes(1);
725
+ expect(mockPlugin.addRoute).toHaveBeenCalledTimes(1);
726
+ });
727
+
728
+ it('should register labelKey on virtualType during apply', () => {
729
+ const mockPlugin = createMockPlugin();
730
+ const mockStore = createMockStore();
731
+ const virtualTypeCalls: any[] = [];
732
+ const mockDSL = {
733
+ product: jest.fn(),
734
+ basicType: jest.fn(),
735
+ labelGroup: jest.fn(),
736
+ setGroupDefaultType: jest.fn(),
737
+ weightGroup: jest.fn(),
738
+ virtualType: jest.fn((...args: any[]) => virtualTypeCalls.push(args)),
739
+ configureType: jest.fn(),
740
+ weightType: jest.fn(),
741
+ };
742
+
743
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
744
+
745
+ const product: ProductMetadata = {
746
+ name: 'my-app',
747
+ labelKey: 'product.myApp.label',
748
+ };
749
+
750
+ const overviewPage: ProductChildCustomPage = {
751
+ name: 'overview',
752
+ labelKey: 'product.myApp.overview',
753
+ component: { name: 'OverviewPage' },
754
+ };
755
+
756
+ const pluginProduct = new PluginProduct(mockPlugin, product, [overviewPage]);
757
+
758
+ pluginProduct.apply(mockPlugin, mockStore);
759
+
760
+ expect(virtualTypeCalls).toHaveLength(1);
761
+ expect(virtualTypeCalls[0][0]).toStrictEqual(expect.objectContaining({ labelKey: 'product.myApp.overview' }));
762
+ });
763
+ });
764
+
765
+ describe('duplicate page name detection', () => {
766
+ it('should throw when two custom pages have the same name in a new product', () => {
767
+ const mockPlugin = createMockPlugin();
768
+ const mockStore = createMockStore();
769
+ const mockDSL = {
770
+ product: jest.fn(),
771
+ basicType: jest.fn(),
772
+ labelGroup: jest.fn(),
773
+ setGroupDefaultType: jest.fn(),
774
+ weightGroup: jest.fn(),
775
+ virtualType: jest.fn(),
776
+ configureType: jest.fn(),
777
+ weightType: jest.fn(),
778
+ };
779
+
780
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
781
+
782
+ const page1: ProductChildCustomPage = {
783
+ name: 'overview',
784
+ label: 'Overview',
785
+ component: { name: 'Page1' },
786
+ };
787
+
788
+ const page2: ProductChildCustomPage = {
789
+ name: 'overview',
790
+ label: 'Overview Duplicate',
791
+ component: { name: 'Page2' },
792
+ };
793
+
794
+ const product: ProductMetadata = {
795
+ name: 'my-app',
796
+ label: 'My App',
797
+ };
798
+
799
+ const pluginProduct = new PluginProduct(mockPlugin, product, [page1, page2]);
800
+
801
+ expect(() => {
802
+ pluginProduct.apply(mockPlugin, mockStore);
803
+ }).toThrow('Duplicate page name "overview"');
804
+ });
805
+
806
+ it('should not throw when pages with the same name are in different groups (different resolved names)', () => {
807
+ const mockPlugin = createMockPlugin();
808
+ const mockStore = createMockStore();
809
+ const mockDSL = {
810
+ product: jest.fn(),
811
+ basicType: jest.fn(),
812
+ labelGroup: jest.fn(),
813
+ setGroupDefaultType: jest.fn(),
814
+ weightGroup: jest.fn(),
815
+ virtualType: jest.fn(),
816
+ configureType: jest.fn(),
817
+ weightType: jest.fn(),
818
+ };
819
+
820
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
821
+
822
+ // Same page name 'overview' but in different groups produces different resolved names
823
+ const standalonePage: ProductChildCustomPage = {
824
+ name: 'cost-analysis',
825
+ label: 'Cost Analysis',
826
+ component: { name: 'CostAnalysis1' },
827
+ };
828
+
829
+ const groupChildPage: ProductChildCustomPage = {
830
+ name: 'cost-analysis',
831
+ label: 'Cost Analysis',
832
+ component: { name: 'CostAnalysis2' },
833
+ };
834
+
835
+ const group: ProductChildGroup = {
836
+ name: 'insights',
837
+ label: 'Insights',
838
+ sideMenu: { children: [groupChildPage] }
839
+
840
+ };
841
+
842
+ const product: ProductMetadata = {
843
+ name: 'my-app',
844
+ label: 'My App',
845
+ };
846
+
847
+ const config: ProductChild[] = [standalonePage, group];
848
+ const pluginProduct = new PluginProduct(mockPlugin, product, config);
849
+
850
+ // Different groups produce different resolved names (myapp-cost-analysis vs myapp-insights-cost-analysis)
851
+ expect(() => {
852
+ pluginProduct.apply(mockPlugin, mockStore);
853
+ }).not.toThrow();
854
+ });
855
+
856
+ it('should throw when two resource pages have the same type', () => {
857
+ const mockPlugin = createMockPlugin();
858
+ const mockStore = createMockStore();
859
+ const mockDSL = {
860
+ product: jest.fn(),
861
+ basicType: jest.fn(),
862
+ labelGroup: jest.fn(),
863
+ setGroupDefaultType: jest.fn(),
864
+ weightGroup: jest.fn(),
865
+ virtualType: jest.fn(),
866
+ configureType: jest.fn(),
867
+ weightType: jest.fn(),
868
+ };
869
+
870
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
871
+
872
+ const resource1: ProductChildResourcePage = { type: 'provisioning.cattle.io.cluster' };
873
+
874
+ const resource2: ProductChildResourcePage = { type: 'provisioning.cattle.io.cluster' };
875
+
876
+ const product: ProductMetadata = {
877
+ name: 'my-app',
878
+ label: 'My App',
879
+ };
880
+
881
+ const pluginProduct = new PluginProduct(mockPlugin, product, [resource1, resource2]);
882
+
883
+ expect(() => {
884
+ pluginProduct.apply(mockPlugin, mockStore);
885
+ }).toThrow('Duplicate resource type "provisioning.cattle.io.cluster"');
886
+ });
887
+
888
+ it('should not throw when pages have different names', () => {
889
+ const mockPlugin = createMockPlugin();
890
+ const mockStore = createMockStore();
891
+ const mockDSL = {
892
+ product: jest.fn(),
893
+ basicType: jest.fn(),
894
+ labelGroup: jest.fn(),
895
+ setGroupDefaultType: jest.fn(),
896
+ weightGroup: jest.fn(),
897
+ virtualType: jest.fn(),
898
+ configureType: jest.fn(),
899
+ weightType: jest.fn(),
900
+ };
901
+
902
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
903
+
904
+ const page1: ProductChildCustomPage = {
905
+ name: 'overview',
906
+ label: 'Overview',
907
+ component: { name: 'Page1' },
908
+ };
909
+
910
+ const page2: ProductChildCustomPage = {
911
+ name: 'settings',
912
+ label: 'Settings',
913
+ component: { name: 'Page2' },
914
+ };
915
+
916
+ const product: ProductMetadata = {
917
+ name: 'my-app',
918
+ label: 'My App',
919
+ };
920
+
921
+ const pluginProduct = new PluginProduct(mockPlugin, product, [page1, page2]);
922
+
923
+ expect(() => {
924
+ pluginProduct.apply(mockPlugin, mockStore);
925
+ }).not.toThrow();
926
+ });
927
+ });
928
+
929
+ describe('group with component route naming', () => {
930
+ it('should include the group name in the virtualType route for side-menu highlighting', () => {
931
+ const mockPlugin = createMockPlugin();
932
+ const mockStore = createMockStore();
933
+ const virtualTypeCalls: any[] = [];
934
+ const mockDSL = {
935
+ product: jest.fn(),
936
+ basicType: jest.fn(),
937
+ labelGroup: jest.fn(),
938
+ setGroupDefaultType: jest.fn(),
939
+ weightGroup: jest.fn(),
940
+ virtualType: jest.fn((...args: any[]) => virtualTypeCalls.push(args)),
941
+ configureType: jest.fn(),
942
+ weightType: jest.fn(),
943
+ };
944
+
945
+ (mockPlugin.DSL as jest.Mock).mockReturnValue(mockDSL);
946
+
947
+ const childPage: ProductChildCustomPage = {
948
+ name: 'alerts',
949
+ label: 'Alerts',
950
+ component: { name: 'AlertsPage' },
951
+ };
952
+
953
+ const monitoringGroup: ProductChildGroup = {
954
+ name: 'monitoring',
955
+ label: 'Monitoring',
956
+ component: { name: 'MonitoringOverview' },
957
+ sideMenu: { children: [childPage] },
958
+ };
959
+
960
+ const product: ProductMetadata = {
961
+ name: 'my-app',
962
+ label: 'My App',
963
+ };
964
+
965
+ const pluginProduct = new PluginProduct(mockPlugin, product, [monitoringGroup]);
966
+
967
+ pluginProduct.apply(mockPlugin, mockStore);
968
+
969
+ // Find the virtualType call for the group (has exact + overview flags)
970
+ const groupVirtualType = virtualTypeCalls.find((call) => call[0].exact === true && call[0].overview === true);
971
+
972
+ expect(groupVirtualType).toBeDefined();
973
+ // The route name should contain the group name 'monitoring', not a generic 'group'
974
+ expect(groupVirtualType[0].route.name).toStrictEqual(expect.stringContaining('monitoring'));
975
+ // It should NOT be a generic route without the group name
976
+ expect(groupVirtualType[0].route.name).not.toBe('myapp-c-cluster');
977
+ });
978
+ });
979
+ });
980
+ });